feat: Enhance mold serial summary retrieval and improve category handling

- Updated the `getMoldSerialSummary` function to dynamically retrieve category values for mold statuses and operations, allowing for more flexible data aggregation.
- Implemented a mapping mechanism to categorize status codes based on their labels, improving the clarity of the summary results.
- Adjusted SQL queries to utilize the new category mappings for more accurate counts of mold statuses.
- Refactored the packaging and loading unit deletion logic to handle company code checks more efficiently, ensuring proper data access control.
This commit is contained in:
kjs
2026-04-16 18:23:20 +09:00
parent c503e2c59c
commit b158b0aa77
49 changed files with 824 additions and 777 deletions
+27 -4
View File
@@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
// 카테고리 코드/영문코드/한글라벨 모두 대응
// 먼저 카테고리 값 조회하여 매핑
// mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회
const catSql = `SELECT value_code, value_label FROM category_values
WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status'))
AND company_code=$1`;
const catRows = await query(catSql, [companyCode]);
// 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성
const codesByLabel: Record<string, string[]> = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] };
for (const cat of catRows) {
const label = cat.value_label || "";
if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code);
else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code);
else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code);
else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code);
}
const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(",");
const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(",");
const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(",");
const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(",");
const sql = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use,
COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair,
COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored,
COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed
FROM mold_serial
WHERE mold_code = $1 AND company_code = $2
`;
@@ -228,10 +228,11 @@ export async function deletePkgUnitItem(
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
const query = companyCode === "*"
? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id`
: `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`;
const params = companyCode === "*" ? [id] : [id, companyCode];
const result = await pool.query(query, params);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
@@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg(
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
const query = companyCode === "*"
? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id`
: `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`;
const params = companyCode === "*" ? [id] : [id, companyCode];
const result = await pool.query(query, params);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -150,17 +150,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -173,11 +173,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -482,9 +478,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -145,17 +145,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -168,11 +168,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -415,9 +411,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -41,6 +41,7 @@ import { PdfUpload } from "@/components/common/PdfUpload";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
@@ -202,6 +203,7 @@ export default function ItemInfoPage() {
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -218,6 +220,7 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
@@ -560,10 +563,14 @@ export default function ItemInfoPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
// 신규 등록: 사용자가 직접 입력한 코드가 있으면 그대로 사용, 없으면 채번
let finalItemNumber = formData.item_number || "";
if (numberingRuleIdRef.current) {
// 채번 미리보기와 사용자 입력값이 다르면 사용자가 직접 수정한 것 → 그대로 사용
const previewCode = numberingParts.length > 0 ? buildCodeFromParts(numberingParts, manualInputValue) : "";
const userModified = finalItemNumber && finalItemNumber !== previewCode;
if (numberingRuleIdRef.current && !userModified) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
@@ -604,19 +611,25 @@ export default function ItemInfoPage() {
}
};
// 삭제
// 삭제 (다중)
const handleDelete = async () => {
if (!selectedId) {
if (checkedIds.length === 0) {
toast.error("삭제할 품목을 선택해 주세요.");
return;
}
if (!confirm("선택한 품목을 삭제할까요?")) return;
const ok = await confirm(`${checkedIds.length}건의 품목을 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
data: checkedIds.map((id) => ({ id })),
});
toast.success("삭제되었어요.");
toast.success(`${checkedIds.length}삭제되었어요.`);
setCheckedIds([]);
setSelectedId(null);
fetchItems();
} catch (err) {
@@ -691,8 +704,8 @@ export default function ItemInfoPage() {
>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
@@ -721,6 +734,9 @@ export default function ItemInfoPage() {
onSelect={(id) => setSelectedId(id)}
onRowDoubleClick={(row) => openEditModal(row)}
showRowNumber
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
draggableColumns={false}
/>
@@ -793,7 +809,7 @@ export default function ItemInfoPage() {
rows={3}
/>
) : field.type === "numbering" ? (
// 채번 세그먼트 UI
// 채번 UI: 자동 채번값이 기본 표시되지만 사용자가 임의로 수정 가능
isEditMode ? (
<Input
value={formData[field.key] || ""}
@@ -805,8 +821,18 @@ export default function ItemInfoPage() {
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
) : (
// 자동 채번값 표시 + 사용자 직접 수정 가능
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData(prev => ({ ...prev, [field.key]: e.target.value }))}
onFocus={(e) => e.target.select()}
placeholder="자동 채번 (직접 입력 가능)"
className="h-9"
/>
)
/* UI
numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
@@ -852,7 +878,6 @@ export default function ItemInfoPage() {
})}
</div>
) : (
// 전체 auto: 읽기전용 표시
<Input
value={formData[field.key] || ""}
disabled
@@ -860,6 +885,7 @@ export default function ItemInfoPage() {
className="h-9 bg-muted"
/>
)
*/
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -916,6 +942,7 @@ export default function ItemInfoPage() {
fetchItems();
}}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1096,6 +1096,33 @@ export default function SubcontractorManagementPage() {
/>
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.representative || ""}
onChange={(e) => setSubcontractorForm((p) => ({ ...p, representative: e.target.value }))}
placeholder="대표이름"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={subcontractorForm.phone || ""}
onChange={(e) => handleFormChange("phone", e.target.value)}
placeholder="02-0000-0000"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.fax || ""}
onChange={(e) => handleFormChange("fax", e.target.value)}
placeholder="02-0000-0000"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs"></Label>
<Input
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,29 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
// 체크박스 기준: 1개만 체크된 경우 수정
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
@@ -1896,6 +1896,33 @@ export default function SupplierManagementPage() {
/>
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={supplierForm.representative_name || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, representative_name: e.target.value }))}
placeholder="대표이름"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Input
value={supplierForm.phone || ""}
onChange={(e) => handleFormChange("phone", e.target.value)}
placeholder="02-0000-0000"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={supplierForm.fax_number || ""}
onChange={(e) => handleFormChange("fax_number", e.target.value)}
placeholder="02-0000-0000"
className="h-9"
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input
@@ -1887,6 +1887,43 @@ export default function CustomerManagementPage() {
/>
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.representative_name || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, representative_name: e.target.value }))}
placeholder="대표이름"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Input
value={customerForm.phone || ""}
onChange={(e) => handleFormChange("phone", e.target.value)}
placeholder="02-0000-0000"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.fax || ""}
onChange={(e) => handleFormChange("fax", e.target.value)}
placeholder="02-0000-0000"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
}, [ts.visibleColumns, catOptions]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipments(raw);
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
@@ -563,10 +563,6 @@ export default function CompanyPage() {
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
return result;
};
(async () => {
try {
const [typeRes, statusRes] = await Promise.all([
apiClient.get("/table-categories/mold_mng/mold_type/values"),
apiClient.get("/table-categories/mold_mng/operation_status/values"),
]);
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
} catch { /* skip */ }
})();
}, []);
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
const resolveOpStatus = (code: string) => {
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
if (catLabel) return catLabel;
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
return legacyMap[code] || code;
};
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const stLabel = resolveOpStatus(mold.operation_status);
const isSelected = selectedMoldCode === mold.mold_code;
return (
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
</div>
</div>
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
)}
</div>
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
<Badge variant="secondary">
{resolveOpStatus(selectedMold.operation_status) || "-"}
</Badge>
</div>
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const ssLabel = resolveOpStatus(s.status);
const maxShot = selectedMold?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1530,53 +1530,6 @@ export default function BomManagementPage() {
</div>
) : (
<div className="flex flex-col h-full">
{/* 상세 카드 */}
<div className="border-b shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<h3 className="text-[13px] font-bold text-foreground">BOM </h3>
<Button size="sm" variant="ghost" onClick={openEditModal}>
<FileText className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : bomHeader ? (
<div className="grid grid-cols-2 text-sm">
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.item_name || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM </span>
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.version || "-"}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b border-r">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
</div>
<div className="flex flex-col gap-1 p-3 border-b">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
{renderStatusBadge(bomHeader.status)}
</div>
<div className="flex flex-col gap-1 p-3 col-span-2">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"></span>
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
</div>
</div>
) : null}
</div>
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<Tabs value={rightTab} onValueChange={(v) => {
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
return () => window.clearTimeout(t);
}, [searchInput]);
// 외주사 목록 로드
useEffect(() => {
(async () => {
try {
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
[details],
[details, subcontractorOptions],
);
return (
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
if (selectedIds.size === 0) {
toast.message("수정할 공정을 체크박스로 선택해주세요");
return;
}
if (selectedIds.size > 1) {
toast.message("수정은 1건만 선택해주세요");
return;
}
const targetId = Array.from(selectedIds)[0];
const target = processes.find((p) => p.id === targetId);
if (!target) {
toast.error("선택한 공정을 찾을 수 없습니다");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setEditingId(target.id);
setFormProcessCode(target.process_code);
setFormProcessName(target.process_name);
setFormProcessType(target.process_type);
setFormStandardTime(target.standard_time ?? "");
setFormWorkerCount(target.worker_count ?? "");
setFormUseYn(target.use_yn);
setFormOpen(true);
};
+4 -3
View File
@@ -715,16 +715,17 @@ export function EDataTable<T extends Record<string, any> = any>({
const id = getRowId(row, rowKey);
const isSelected = selectedId != null && String(selectedId) === String(id);
const isChecked = checkedIds.includes(id);
const highlighted = isSelected || isChecked;
return (
<TableRow
key={id || rowIdx}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
highlighted
isSelected
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
: "hover:bg-accent"
: isChecked
? "bg-muted/50"
: "hover:bg-accent"
)}
onClick={() => {
onSelect?.(id);