feat: Implement copy functionality for item inspection information
- Added a modal for copying inspection information from a selected item to multiple target items. - Implemented search and selection logic for target items to facilitate the copying process. - Included validation to ensure a source item is selected and that target items are valid before proceeding with the copy operation. - Enhanced user feedback with toast notifications for successful and error states during the copy process. - Updated BOM management to include unit label handling for better clarity in item representation.
This commit is contained in:
@@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||
const topLevelOnly = req.query.topLevelOnly === "true";
|
||||
|
||||
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||
@@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -223,13 +223,14 @@ class CategoryTreeService {
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
(SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
|
||||
@@ -167,7 +167,8 @@ class TableCategoryValueService {
|
||||
columnName: string,
|
||||
companyCode: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
): Promise<TableCategoryValue[]> {
|
||||
try {
|
||||
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
|
||||
@@ -235,6 +236,10 @@ class TableCategoryValueService {
|
||||
query += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
if (topLevelOnly) {
|
||||
query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY value_order, value_label`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,13 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
// 하위분류 사용 여부 (카테고리별 자동감지 + 수동 토글)
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +63,72 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 카테고리 선택 변경 시 하위분류 존재 여부 자동감지
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +186,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +219,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
|
||||
@@ -323,7 +323,7 @@ export default function ChunganSalesOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1172,6 +1274,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
|
||||
@@ -427,8 +427,8 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
return result;
|
||||
}, [data, headerFilters, sortState, onSortChange]);
|
||||
|
||||
// 필터/데이터 변경 시 1페이지 리셋
|
||||
useEffect(() => { setCurrentPage(1); }, [data, headerFilters]);
|
||||
// 필터/데이터 건수 변경 시 1페이지 리셋 (참조만 바뀐 경우는 리셋 안 함)
|
||||
useEffect(() => { setCurrentPage(1); }, [data.length, headerFilters]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalItems = processedData.length;
|
||||
|
||||
@@ -51,13 +51,17 @@ export async function getCategoryValues(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
) {
|
||||
try {
|
||||
const params: any = { includeInactive };
|
||||
if (menuObjid) {
|
||||
params.menuObjid = menuObjid;
|
||||
}
|
||||
if (topLevelOnly) {
|
||||
params.topLevelOnly = true;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user