feat: Enhance routing details and subcontractor mapping functionality

- Updated the `getRoutingDetails` function to enrich routing details with subcontractor codes, improving data retrieval for routing information.
- Implemented a mapping mechanism to associate subcontractor codes with routing details, ensuring accurate representation of outsourcing suppliers.
- Enhanced the `saveRoutingDetails` function to handle subcontractor mappings during the save operation, ensuring data integrity and consistency.
- Updated the BOM service to improve unit handling and added inventory unit information for better clarity in item representation.
- Refactored production plan management to streamline modal handling and improve error messaging for better user feedback.
This commit is contained in:
kjs
2026-04-17 18:25:35 +09:00
parent 78e102fd45
commit 48b9ba3d2a
31 changed files with 2234 additions and 546 deletions
@@ -382,7 +382,31 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
[versionId, companyCode]
);
return res.json({ success: true, data: result.rows });
const rows = result.rows;
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
let mappingByDetail: Record<string, string[]> = {};
if (detailIds.length > 0) {
const mapRes = await pool.query(
`SELECT routing_detail_id, subcontractor_code
FROM item_routing_subcontractor
WHERE routing_detail_id = ANY($1::uuid[])
ORDER BY seq_order`,
[detailIds]
);
for (const m of mapRes.rows) {
const key = String(m.routing_detail_id);
if (!mappingByDetail[key]) mappingByDetail[key] = [];
mappingByDetail[key].push(m.subcontractor_code);
}
}
const enriched = rows.map((r: any) => {
const list = mappingByDetail[String(r.id)] || [];
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장
if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier);
return { ...r, outsource_supplier_list: list };
});
return res.json({ success: true, data: enriched });
} catch (error: any) {
logger.error("라우팅 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
@@ -400,6 +424,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
try {
await client.query("BEGIN");
// 기존 상세의 외주업체 매핑을 먼저 제거
await client.query(
`DELETE FROM item_routing_subcontractor
WHERE routing_detail_id IN (
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
)`,
[versionId, companyCode]
);
// 기존 상세 삭제 후 재입력
await client.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
@@ -407,11 +440,26 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
);
for (const d of details) {
await client.query(
const suppliers: string[] = Array.isArray(d.outsource_supplier_list)
? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "")
: (d.outsource_supplier ? [d.outsource_supplier] : []);
const primaryLegacy = suppliers[0] || d.outsource_supplier || "";
const insertRes = await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer]
);
const newDetailId = insertRes.rows[0].id;
for (let i = 0; i < suppliers.length; i++) {
await client.query(
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order)
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
[companyCode, newDetailId, suppliers[i], i]
);
}
}
await client.query("COMMIT");
+2 -1
View File
@@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) {
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type,
COALESCE(b.unit, i.unit) as unit,
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
i.unit as item_unit,
i.inventory_unit as item_inventory_unit,
i.division, i.size, i.material
FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id
@@ -694,13 +694,16 @@ export async function mergeSchedules(
[companyCode, ...scheduleIds]
);
// 병합된 스케줄 생성
// 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@@ -1017,13 +1020,16 @@ export async function splitSchedule(
[originalQty - splitQty, splitBy, planId, companyCode]
);
// 분할된 새 계획 생성
// 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -247,6 +247,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -353,7 +355,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -383,10 +389,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -738,7 +744,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1050,9 +1056,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1078,24 +1084,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,7 +92,7 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,10 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
setFormOutsources(loaded);
setDetailDialogOpen(true);
};
@@ -329,7 +333,8 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimary = outsourceList[0] || "";
setDetailSubmitting(true);
try {
@@ -344,7 +349,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimary,
outsource_supplier_list: outsourceList,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +368,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimary,
outsource_supplier_list: outsourceList,
}
: d,
),
@@ -399,6 +406,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []),
}));
setSaving(true);
@@ -480,11 +488,19 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0
? d.outsource_supplier_list
: (d.outsource_supplier ? [d.outsource_supplier] : []);
const names = codes
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
.filter(Boolean);
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +925,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<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>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.code);
return (
<label
key={s.code}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.code] : prev.filter((c) => c !== s.code),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -154,6 +154,7 @@ const FORM_FIELDS = [
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "expiry", label: "유효기간", type: "expiry" },
{ key: "image", label: "품목 이미지", type: "image" },
{ key: "meno", label: "메모", type: "textarea" },
] as const;
@@ -170,6 +171,21 @@ const formatNum = (val: any): string => {
return isNaN(n) ? String(val) : n.toLocaleString();
};
// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략)
const formatExpirySummary = (y: any, m: any, d: any): string => {
const toInt = (v: any) => {
if (v === null || v === undefined || v === "") return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.floor(n);
};
const years = toInt(y), months = toInt(m), days = toInt(d);
const parts: string[] = [];
if (years) parts.push(`${years}`);
if (months) parts.push(`${months}개월`);
if (days) parts.push(`${days}`);
return parts.join(" ");
};
const ITEM_GRID_COLUMNS = [
{ key: "item_number", label: "품번" },
{ key: "item_name", label: "품명" },
@@ -177,6 +193,7 @@ const ITEM_GRID_COLUMNS = [
{ key: "inventory_unit", label: "단위" },
{ key: "standard_price", label: "기준단가/구매단가" },
{ key: "currency_code", label: "통화" },
{ key: "expiry_summary", label: "유효기간" },
{ key: "status", label: "상태" },
];
@@ -339,6 +356,7 @@ export default function PurchaseItemPage() {
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days);
return converted;
});
setItems(data);
@@ -550,7 +568,7 @@ export default function PurchaseItemPage() {
setSaving(true);
try {
if (isEditMode && editId) {
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
@@ -583,7 +601,7 @@ export default function PurchaseItemPage() {
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
const { id, created_date, updated_date, expiry_summary, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
@@ -1166,6 +1184,7 @@ export default function PurchaseItemPage() {
inventory_unit: { width: "w-[60px]" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]" },
expiry_summary: { width: "w-[110px]" },
status: { width: "w-[60px]" },
};
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
@@ -1596,6 +1615,33 @@ export default function PurchaseItemPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "expiry" ? (
<div className="grid grid-cols-3 gap-2">
{[
{ key: "expiry_years", unit: "년" },
{ key: "expiry_months", unit: "개월" },
{ key: "expiry_days", unit: "일" },
].map(({ key, unit }) => (
<div key={key} className="flex items-center gap-1">
<Input
type="number"
min={0}
step={1}
value={formData[key] ?? ""}
onChange={(e) => {
const v = e.target.value;
setFormData((prev) => ({
...prev,
[key]: v === "" ? null : Math.max(0, Math.floor(Number(v))),
}));
}}
placeholder="0"
className="h-9 text-right"
/>
<span className="text-sm text-muted-foreground shrink-0">{unit}</span>
</div>
))}
</div>
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -49,6 +49,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -13,7 +13,11 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
GripVertical,
} from "lucide-react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -43,6 +47,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -50,10 +55,36 @@ type InspectionRow = {
unit?: string; // 검사 단위
};
function SortableInspectionTableRow({ id, children }: { id: string; children: (dragHandle: React.ReactNode) => React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const handle = (
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground/60 hover:text-muted-foreground"
aria-label="순서 변경"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
);
return (
<TableRow ref={setNodeRef} style={style}>
{children(handle)}
</TableRow>
);
}
export default function ItemInspectionInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -402,7 +433,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +473,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +506,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,9 +525,18 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const reorderInspRows = (typeKey: string, fromId: string, toId: string) => {
setInspectionRows(prev => {
const list = prev[typeKey] || [];
const fromIdx = list.findIndex(r => r.id === fromId);
const toIdx = list.findIndex(r => r.id === toId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return prev;
return { ...prev, [typeKey]: arrayMove(list, fromIdx, toIdx) };
});
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
@@ -542,18 +596,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,15 +1033,17 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1001,6 +1062,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1015,6 +1077,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1172,21 +1242,38 @@ export default function ItemInspectionInfoPage() {
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[28px]" />
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableRow><TableCell colSpan={11} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : (
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragEnd={(e: DragEndEvent) => {
const { active, over } = e;
if (over && active.id !== over.id) {
reorderInspRows(key, String(active.id), String(over.id));
}
}}
>
<SortableContext items={inspectionRows[key].map(r => r.id)} strategy={verticalListSortingStrategy}>
{inspectionRows[key].map((row) => (
<SortableInspectionTableRow key={row.id} id={row.id}>
{(dragHandle) => (<>
<TableCell className="p-1 text-center">{dragHandle}</TableCell>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
@@ -1209,6 +1296,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1250,11 +1340,16 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
</TableRow>
))}
</>)}
</SortableInspectionTableRow>
))}
</SortableContext>
</DndContext>
)}
</TableBody>
</Table>
</div>
@@ -145,6 +145,21 @@ const formatNum = (val: any): string => {
return isNaN(n) ? String(val) : n.toLocaleString();
};
// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략)
const formatExpirySummary = (y: any, m: any, d: any): string => {
const toInt = (v: any) => {
if (v === null || v === undefined || v === "") return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.floor(n);
};
const years = toInt(y), months = toInt(m), days = toInt(d);
const parts: string[] = [];
if (years) parts.push(`${years}`);
if (months) parts.push(`${months}개월`);
if (days) parts.push(`${days}`);
return parts.join(" ");
};
const ITEM_GRID_COLUMNS = [
{ key: "item_number", label: "품번" },
{ key: "item_name", label: "품명" },
@@ -153,6 +168,7 @@ const ITEM_GRID_COLUMNS = [
{ key: "standard_price", label: "기준단가" },
{ key: "selling_price", label: "판매가격" },
{ key: "currency_code", label: "통화" },
{ key: "expiry_summary", label: "유효기간" },
{ key: "status", label: "상태" },
];
@@ -175,6 +191,7 @@ const FORM_FIELDS = [
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "expiry", label: "유효기간", type: "expiry" },
{ key: "image", label: "품목 이미지", type: "image" },
{ key: "meno", label: "메모", type: "textarea" },
];
@@ -340,6 +357,7 @@ export default function SalesItemPage() {
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days);
return converted;
});
setItems(data);
@@ -1044,7 +1062,7 @@ export default function SalesItemPage() {
setSaving(true);
try {
if (isEditMode && editId) {
const { id, created_date, updated_date, writer, company_code, ...updateFields } = editItemForm;
const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = editItemForm;
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
@@ -1077,7 +1095,7 @@ export default function SalesItemPage() {
}
}
const { id, created_date, updated_date, ...insertFields } = editItemForm;
const { id, created_date, updated_date, expiry_summary, ...insertFields } = editItemForm;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
@@ -1175,6 +1193,7 @@ export default function SalesItemPage() {
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "expiry_summary", label: "유효기간", width: "w-[110px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
@@ -1598,6 +1617,33 @@ export default function SalesItemPage() {
placeholder={field.label}
rows={3}
/>
) : field.type === "expiry" ? (
<div className="grid grid-cols-3 gap-2">
{[
{ key: "expiry_years", unit: "년" },
{ key: "expiry_months", unit: "개월" },
{ key: "expiry_days", unit: "일" },
].map(({ key, unit }) => (
<div key={key} className="flex items-center gap-1">
<Input
type="number"
min={0}
step={1}
value={editItemForm[key] ?? ""}
onChange={(e) => {
const v = e.target.value;
setEditItemForm((prev) => ({
...prev,
[key]: v === "" ? null : Math.max(0, Math.floor(Number(v))),
}));
}}
placeholder="0"
className="h-9 text-right"
/>
<span className="text-sm text-muted-foreground shrink-0">{unit}</span>
</div>
))}
</div>
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={editItemForm[field.key] ? Number(String(editItemForm[field.key]).replace(/,/g, "")).toLocaleString() : ""}
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
{ key: "warehouse_code", label: "창고코드" },
{ key: "warehouse_name", label: "창고명" },
{ key: "warehouse_type", label: "유형" },
{ key: "manager", label: "관리자" },
{ key: "manager_name", label: "관리자" },
{ key: "status", label: "상태" },
];
const LOCATION_TABLE = "warehouse_location";
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const data = raw.map((r: any) => ({
...r,
_warehouse_type_code: r.warehouse_type,
_status_code: r.status,
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
status: resolveCategory(categoryOptions, "status", r.status),
}));
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
const openWarehouseEditModal = (row: any) => {
setWarehouseEditMode(true);
setWarehouseForm({ ...row });
setWarehouseForm({
...row,
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
status: row._status_code ?? row.status ?? "",
});
setWarehouseModalOpen(true);
};
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
warehouse_code: finalWarehouseCode,
warehouse_name: warehouseForm.warehouse_name?.trim(),
warehouse_type: warehouseForm.warehouse_type || "",
manager: warehouseForm.manager || "",
address: warehouseForm.address || "",
manager_name: warehouseForm.manager_name || "",
contact: warehouseForm.contact || "",
status: warehouseForm.status || "",
description: warehouseForm.description || "",
memo: warehouseForm.memo || "",
};
// 신규 등록 시 창고코드 중복 체크
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
창고코드: r.warehouse_code,
창고명: r.warehouse_name,
유형: r.warehouse_type,
관리자: r.manager,
관리자: r.manager_name,
상태: r.status,
})),
"창고정보"
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.manager || ""}
value={warehouseForm.manager_name || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
}
placeholder="관리자를 입력해주세요"
/>
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
</SelectContent>
</Select>
</div>
{/* 주소 (전체 너비) */}
{/* 연락처 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.address || ""}
value={warehouseForm.contact || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
}
placeholder="주소를 입력해주세요"
placeholder="연락처를 입력해주세요"
/>
</div>
{/* 비고 (전체 너비) */}
<div className="grid gap-1.5 col-span-2">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={warehouseForm.description || ""}
value={warehouseForm.memo || ""}
onChange={(e) =>
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
}
placeholder="비고를 입력해주세요"
/>
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
const [modalQuantity, setModalQuantity] = useState(0);
const [modalStartDate, setModalStartDate] = useState("");
const [modalEndDate, setModalEndDate] = useState("");
const [modalManager, setModalManager] = useState("");
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
const [modalRemarks, setModalRemarks] = useState("");
const [modalEquipmentId, setModalEquipmentId] = useState("");
// 미리보기 데이터
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
// useConfirmDialog
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
// 수량 지정 분할 입력값
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
// ========== 데이터 로드 ==========
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
setModalQuantity(Number(plan.plan_qty));
setModalStartDate(plan.start_date?.split("T")[0] || "");
setModalEndDate(plan.end_date?.split("T")[0] || "");
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setCustomSplitQty("");
setScheduleModalOpen(true);
}, []);
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
toast.success("생산계획이 수정되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("수정 실패: " + (err.message || ""));
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
} finally {
setSaving(false);
}
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
const handleDeletePlan = useCallback(async () => {
if (!selectedPlan) return;
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
toast.success("삭제되었습니다");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("삭제 실패: " + (err.message || ""));
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlan, fetchPlans, confirm]);
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
// 에러 메시지 추출 헬퍼
const extractErrMsg = (err: any): string => {
return err?.response?.data?.message || err?.message || "";
};
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
const isModalDirty = useCallback((): boolean => {
if (!selectedPlan) return false;
const planQty = Number(selectedPlan.plan_qty) || 0;
const planStart = selectedPlan.start_date?.split("T")[0] || "";
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
return (
planQty !== Number(modalQuantity) ||
planStart !== modalStartDate ||
planEnd !== modalEndDate ||
planEq !== modalEquipmentId
);
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
if (!selectedPlan) return false;
if (!isModalDirty()) return true;
try {
const res = await updatePlan(selectedPlan.id, {
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (!res.success) {
toast.error("저장 실패로 분할이 중단되었습니다");
return false;
}
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
setSelectedPlan((prev) => prev ? ({
...prev,
plan_qty: modalQuantity,
start_date: modalStartDate,
end_date: modalEndDate,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
} as any) : prev);
return true;
} catch (err: any) {
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
return false;
}
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
// 균등 분할 (2/3/4분할 버튼)
const handleSplitSchedule = useCallback(async (splitCount: number) => {
if (!selectedPlan || splitCount < 2) return;
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
const originalQty = Number(modalQuantity) || 0;
if (originalQty < splitCount) {
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
confirmText: "분할",
});
if (!ok) return;
// dirty 면 자동 저장
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const eachQty = Math.floor(originalQty / splitCount);
if (eachQty <= 0) {
toast.error("분할 수량이 부족합니다");
return;
}
let successCount = 0;
try {
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
for (let i = 0; i < splitCount - 1; i++) {
const res = await splitSchedule(selectedPlan.id, eachQty);
if (!res.success) throw new Error("분할 응답 실패");
successCount++;
}
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
const msg = extractErrMsg(err);
if (successCount > 0) {
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
} else {
toast.error("분할 실패: " + msg);
}
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
const handleCustomSplit = useCallback(async () => {
if (!selectedPlan) return;
const splitQty = Number(customSplitQty);
const originalQty = Number(modalQuantity) || 0;
if (!splitQty || splitQty < 1) {
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
return;
}
if (splitQty >= originalQty) {
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
return;
}
if (selectedPlan.status && selectedPlan.status !== "planned") {
toast.error("계획 상태인 건만 분할할 수 있습니다");
return;
}
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
confirmText: "분할",
});
if (!ok) return;
const saved = await ensureSavedBeforeSplit();
if (!saved) return;
const handleSplitSchedule = useCallback(async (splitQty: number) => {
if (!selectedPlan || splitQty <= 0) return;
try {
const res = await splitSchedule(selectedPlan.id, splitQty);
if (res.success) {
toast.success("계획이 분되었습니다");
setScheduleModalOpen(false);
fetchPlans();
}
if (!res.success) throw new Error("분할 응답 실패");
toast.success(`${splitQty} 수량이 분되었습니다`);
setCustomSplitQty("");
setScheduleModalOpen(false);
fetchPlans();
fetchOrderSummary();
} catch (err: any) {
toast.error("분할 실패: " + (err.message || ""));
toast.error("분할 실패: " + extractErrMsg(err));
fetchPlans();
fetchOrderSummary();
}
}, [selectedPlan, fetchPlans]);
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
// 병합 핸들러
const handleMergeSchedules = useCallback(async () => {
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
toast.success("계획이 병합되었습니다");
setSelectedPlanIds(new Set());
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("병합 실패: " + (err.message || ""));
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
}
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
// 타임라인 이벤트 드래그 이동
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("일정이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("일정 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 타임라인 이벤트 리사이즈
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
if (res.success) {
toast.success("기간이 변경되었습니다");
fetchPlans();
fetchOrderSummary();
}
} catch (err: any) {
toast.error("기간 변경 실패: " + (err.message || ""));
}
}, [fetchPlans]);
}, [fetchPlans, fetchOrderSummary]);
// 불러오기 처리
const handleImportOrderItems = useCallback(async () => {
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
{/* ========== 모달들 ========== */}
{/* 스케줄 상세/편집 모달 */}
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
<Dialog
open={scheduleModalOpen}
onOpenChange={(v) => {
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
if (!v && isConfirmOpenRef.current) return;
setScheduleModalOpen(v);
}}
>
<DialogContent
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
onPointerDownOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
onFocusOutside={(e) => {
if (isConfirmOpenRef.current) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5" />
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
<Scissors className="h-4 w-4" />
</p>
<div className="flex gap-1.5">
{[2, 3, 4].map((n) => {
const canSplit =
modalQuantity >= n &&
(selectedPlan?.status === "planned" || !selectedPlan?.status);
return (
<Button
key={n}
size="sm"
variant="warning"
className="h-7 text-xs"
disabled={!canSplit}
onClick={() => handleSplitSchedule(n)}
>
{n}
</Button>
);
})}
</div>
</div>
<p className="text-xs text-foreground mb-2">
. ( )
</p>
{/* 수량 지정 분할 */}
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
<Label className="text-xs text-muted-foreground shrink-0"> :</Label>
<Input
type="number"
value={customSplitQty}
onChange={(e) => {
const v = e.target.value;
if (v === "") setCustomSplitQty("");
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
}}
className="h-7 w-28 text-xs"
placeholder="떼어낼 수량"
min={1}
max={Math.max(0, modalQuantity - 1)}
step={1}
/>
<span className="text-xs text-muted-foreground">
/ {modalQuantity}
</span>
<Button
size="sm"
variant="warning"
className="h-7 text-xs"
onClick={() => {
const qty = Math.floor(modalQuantity / 2);
if (qty > 0) handleSplitSchedule(qty);
}}
className="h-7 text-xs ml-auto"
disabled={
!customSplitQty ||
Number(customSplitQty) < 1 ||
Number(customSplitQty) >= modalQuantity ||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
}
onClick={handleCustomSplit}
>
2
</Button>
</div>
<p className="text-xs text-foreground"> .</p>
</div>
<div>
<p className="text-sm font-semibold mb-3 pb-2 border-b"> </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
</div>
<div>
<Label className="text-xs"></Label>
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
</div>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
. (1 , )
</p>
</div>
</div>
)}
@@ -977,12 +977,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1015,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
const unitCode = insp?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return unitLabel || "-";
})()}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -1179,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1250,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
</TableCell>
@@ -223,6 +223,9 @@ export default function TimelineScheduler({
const gridRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// 드래그 이동(move) 직후 자동 발생하는 click 이벤트 1회를 무시하기 위한 플래그.
// 드래그로 일정이 변경된 직후에 모달이 자동 오픈되면서 이전 날짜가 표시되는 버그(TASK:ERP-006) 방지용.
const justDraggedRef = useRef(false);
// 줌 레벨 동기화
useEffect(() => {
@@ -404,6 +407,12 @@ export default function TimelineScheduler({
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = toDateStr(addDays(origEnd, dayOffset));
onEventMove?.(dragState.eventId, newStart, newEnd);
// 드래그 직후 브라우저가 자동 디스패치하는 click 이벤트 1회를 무시해
// 모달이 이전 날짜로 자동 오픈되는 버그(TASK:ERP-006) 방지.
justDraggedRef.current = true;
setTimeout(() => {
justDraggedRef.current = false;
}, 0);
} else if (dragState.mode === "resize-left") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = dragState.origEndDate.split("T")[0];
@@ -411,12 +420,20 @@ export default function TimelineScheduler({
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
justDraggedRef.current = true;
setTimeout(() => {
justDraggedRef.current = false;
}, 0);
} else if (dragState.mode === "resize-right") {
const newStart = dragState.origStartDate.split("T")[0];
const newEnd = toDateStr(addDays(origEnd, dayOffset));
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
justDraggedRef.current = true;
setTimeout(() => {
justDraggedRef.current = false;
}, 0);
}
}
@@ -770,6 +787,11 @@ export default function TimelineScheduler({
}}
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
onClick={(e) => {
// 드래그 직후 자동 발생하는 click은 무시 (TASK:ERP-006).
if (justDraggedRef.current) {
e.stopPropagation();
return;
}
if (!isDragging) {
e.stopPropagation();
onEventClick?.(ev);
+1
View File
@@ -58,6 +58,7 @@ export interface RoutingDetail {
work_type: string;
standard_time: string;
outsource_supplier: string;
outsource_supplier_list?: string[];
}
interface ApiResponse<T> {
@@ -191,7 +191,7 @@ export function BomTreeComponent({
item_number: headerData.item_code || "",
quantity: "-",
base_qty: headerData.base_qty || "",
unit: headerData.unit || "",
unit: headerData.unit || (headerData as any).item_unit || (headerData as any).item_inventory_unit || (headerData as any).inventory_unit || "",
revision: headerData.revision || "",
loss_rate: "",
process_type: "",
@@ -311,7 +311,7 @@ export function BomTreeComponent({
item_name: raw.item_name || "",
item_code: raw.item_number || raw.item_code || "",
item_type: raw.item_type || raw.division || "",
unit: raw.unit || raw.item_unit || "",
unit: raw.unit || raw.item_unit || raw.item_inventory_unit || raw.inventory_unit || "",
} as BomHeaderInfo;
}
} catch (e) {