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:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user