From bf58ce3c07c305a1412bc0d2e8d485b97c29672c Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 22 Apr 2026 15:44:42 +0900 Subject: [PATCH 1/3] feat: Implement multi-select functionality for work instruction items - Added new fields to the SelectedItem interface for managing item schedules, equipment, work teams, and workers. - Created a reusable MultiSelectPopover component to facilitate multi-selection of equipment, work teams, and workers. - Enhanced the applyRegistration function to include start and end dates, as well as equipment and team assignments for work instruction items. - Updated item handling logic to support production planning with optional scheduling details, improving the overall functionality of the work instruction page. --- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- .../production/work-instruction/page.tsx | 295 +++++++++++++++--- 6 files changed, 1488 insertions(+), 282 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index 9a6fc954..110b2b4d 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -197,17 +281,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -256,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -265,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -292,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -322,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -333,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -625,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -634,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

- +
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -674,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -696,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -716,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -725,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -809,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index 114b92a1..064a7dae 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -201,17 +285,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -260,6 +349,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -269,11 +361,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -296,6 +406,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -326,9 +442,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -337,11 +457,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -629,7 +768,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -638,38 +777,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -678,7 +812,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -700,6 +834,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -720,7 +888,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -729,48 +897,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -813,6 +980,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx index 9a6fc954..110b2b4d 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -197,17 +281,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -256,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -265,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -292,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -322,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -333,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -625,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -634,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -674,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -696,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -716,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -725,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -809,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx index c01796fe..4e66d483 100644 --- a/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -207,17 +291,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -266,6 +355,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -275,11 +367,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -302,6 +412,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -332,9 +448,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -343,11 +463,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -641,7 +780,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -650,38 +789,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -690,7 +824,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -712,6 +846,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -732,7 +900,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -741,48 +909,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -825,6 +992,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx index 9a6fc954..110b2b4d 100644 --- a/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -197,17 +281,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -256,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -265,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -292,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -322,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -333,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -625,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -634,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -674,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -696,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -716,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -725,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -809,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx index 9a6fc954..110b2b4d 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -197,17 +281,22 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null ? Number(item.remain_qty) : Number(item.plan_qty || 1); - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); } } @@ -256,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -265,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -292,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -322,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -333,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -625,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -634,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -674,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -696,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -716,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -725,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -809,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> From ad1180daa50ce53d1f642bd0784e9f8ec7a26dc4 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 22 Apr 2026 18:09:57 +0900 Subject: [PATCH 2/3] feat: Allow duplicate items at the same level in BOM management - Updated the logic in the BomManagementPage to permit the registration of duplicate items at the same level, enabling separate rows for items with different requirements or processes. - Removed the previous check for duplicate items at the same level, enhancing flexibility in item management within the BOM structure. --- .../(main)/COMPANY_10/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_16/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_29/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_30/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_7/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_8/production/bom/page.tsx | 9 +---- .../(main)/COMPANY_9/production/bom/page.tsx | 9 +---- .../components/DetailFormModal.tsx | 35 +++++++++++++++---- 8 files changed, 35 insertions(+), 63 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 01e7ee14..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -834,14 +834,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index 01e7ee14..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -834,14 +834,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx index 01e7ee14..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_29/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/bom/page.tsx @@ -834,14 +834,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx index d3c52b1a..e44cd38f 100644 --- a/frontend/app/(main)/COMPANY_30/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_30/production/bom/page.tsx @@ -841,14 +841,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 01e7ee14..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -834,14 +834,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_8/production/bom/page.tsx b/frontend/app/(main)/COMPANY_8/production/bom/page.tsx index 01e7ee14..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_8/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_8/production/bom/page.tsx @@ -834,14 +834,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/app/(main)/COMPANY_9/production/bom/page.tsx b/frontend/app/(main)/COMPANY_9/production/bom/page.tsx index 51bc486a..3d27f801 100644 --- a/frontend/app/(main)/COMPANY_9/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/bom/page.tsx @@ -841,14 +841,7 @@ export default function BomManagementPage() { return; } - // 같은 레벨(같은 부모) 중복 품목 체크 - const siblings = addTargetParentId - ? (findNodeById(editingTree, addTargetParentId)?.children || []) - : editingTree; - if (siblings.some((n) => n.child_item_id === item.id)) { - toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); - return; - } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index d2ba60fc..78a9b2bc 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -131,13 +131,33 @@ export function DetailFormModal({ }, [selectedItemCode]); // BOM 자재 로드 완료 후 체크 상태 초기화 + // bomChecked 키는 BOM detail 고유 id(mat.id) 기준 — 동일 child_item_id가 여러 row로 + // 있어도 독립적으로 체크되도록 함. legacy 저장값은 child_item_id로 저장되어 있을 수 + // 있으므로 폴백 매핑으로 복원. useEffect(() => { if (!open || bomMaterials.length === 0) return; if (mode === "edit" && editData?.selected_bom_items) { const savedBom = editData.selected_bom_items; const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; if (Array.isArray(parsedBom)) { - setBomChecked(new Set(parsedBom)); + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); // 신규 포맷: bom_detail id 저장값 + } else { + // legacy 폴백: child_item_id로 저장된 값 → 해당 품목의 첫 번째 행 1건 자동 체크 + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); return; } } @@ -310,8 +330,9 @@ export function DetailFormModal({ submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; } if (type === "material_input") { - // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 - const checkedMats = bomMaterials.filter(m => bomChecked.has(m.child_item_id)); + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 — 동일 품목 중복 row를 독립 + // 처리하기 위해 bom_detail 고유 id(m.id) 기준으로 매칭 + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); if (checkedMats.length > 0) { const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; for (const mat of checkedMats) { @@ -955,7 +976,7 @@ export function DetailFormModal({ } onCheckedChange={(checked) => { if (checked) { - setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id))); + setBomChecked(new Set(bomMaterials.map((m) => m.id))); } else { setBomChecked(new Set()); } @@ -994,12 +1015,12 @@ export function DetailFormModal({ className="flex items-center gap-2.5 border-b px-3 py-2.5 last:border-b-0 hover:bg-sky-50/50" > { setBomChecked((prev) => { const next = new Set(prev); - if (checked) next.add(mat.child_item_id); - else next.delete(mat.child_item_id); + if (checked) next.add(mat.id); + else next.delete(mat.id); return next; }); }} From 1282955d15131ebe7364a55e329f7faa6ea5e1bc Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 23 Apr 2026 09:44:50 +0900 Subject: [PATCH 3/3] feat: Enhance warehouse management page with drag-and-drop functionality - Added GripVertical icon for visual representation of draggable segments. - Introduced state management for rack segment order, allowing users to customize the order of segments (zone, row, level) via drag-and-drop. - Updated location code generation logic to reflect the new segment order, improving the flexibility of location naming. - Simplified modal handling by resetting segment order and labels upon opening the rack modal. - Adjusted validation messages to focus on required fields, enhancing user experience during rack structure registration. --- .../COMPANY_7/logistics/warehouse/page.tsx | 187 ++++++++++-------- 1 file changed, 103 insertions(+), 84 deletions(-) diff --git a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx index 544ab0c4..b0df815f 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx @@ -55,6 +55,7 @@ import { Layers, Info, Eye, + GripVertical, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -149,7 +150,6 @@ export default function WarehouseManagementPage() { // 모달: 랙 구조 일괄 등록 const [rackModalOpen, setRackModalOpen] = useState(false); - const [rackFloor, setRackFloor] = useState(""); const [rackZone, setRackZone] = useState(""); const [rackConditions, setRackConditions] = useState< { id: string; startRow: number; endRow: number; levels: number }[] @@ -162,6 +162,10 @@ export default function WarehouseManagementPage() { const [rackZoneLabel, setRackZoneLabel] = useState("구역"); const [rackRowLabel, setRackRowLabel] = useState("열"); const [rackLevelLabel, setRackLevelLabel] = useState("단"); + // 위치명 세그먼트 순서 (드래그로 변경 가능) + const [rackSegmentOrder, setRackSegmentOrder] = useState<("zone" | "row" | "level")[]>(["zone", "row", "level"]); + const [draggedSegment, setDraggedSegment] = useState(null); + const [dragOverSegment, setDragOverSegment] = useState(null); // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState< @@ -199,7 +203,7 @@ export default function WarehouseManagementPage() { setCategoryOptions(whOpts); const locOpts: Record = {}; - for (const col of ["location_type", "status", "floor", "zone"]) { + for (const col of ["location_type", "status", "zone"]) { try { const res = await apiClient.get( `/table-categories/${LOCATION_TABLE}/${col}/values` @@ -512,7 +516,7 @@ export default function WarehouseManagementPage() { warehouse_code: locationForm.warehouse_code || selectedWarehouse?.warehouse_code || "", location_code: finalLocationCode, location_name: locationForm.location_name?.trim(), - floor: locationForm.floor || "", + floor: "", zone: locationForm.zone || "", row_num: locationForm.row_num || "", level_num: locationForm.level_num || "", @@ -570,13 +574,16 @@ export default function WarehouseManagementPage() { // ─── 랙 구조 일괄 등록 ─── const openRackModal = () => { - setRackFloor(""); setRackZone(""); setRackConditions([]); setRackLocationType(""); setRackStatus(""); setRackPreview([]); setRackSaving(false); + setRackSegmentOrder(["zone", "row", "level"]); + setRackZoneLabel("구역"); + setRackRowLabel("열"); + setRackLevelLabel("단"); setRackModalOpen(true); }; @@ -601,8 +608,8 @@ export default function WarehouseManagementPage() { }; const generateRackPreview = () => { - if (!rackFloor.trim() || !rackZone.trim()) { - toast.error("층과 구역을 입력해주세요"); + if (!rackZone.trim()) { + toast.error("구역을 선택해주세요"); return; } if (rackConditions.length === 0) { @@ -623,11 +630,8 @@ export default function WarehouseManagementPage() { const whCode = selectedWarehouse?.warehouse_code || ""; // 카테고리 코드→라벨 변환 (셀렉트에서 코드가 저장되므로) - const floorOpts = locationCategoryOptions["floor"] || []; const zoneOpts = locationCategoryOptions["zone"] || []; - const floorLabel = floorOpts.find(o => o.code === rackFloor)?.label || rackFloor.trim(); const zoneLabel = zoneOpts.find(o => o.code === rackZone)?.label || rackZone.trim(); - const floorCode = floorLabel.replace(/층$/, ""); const zoneCode = zoneLabel.replace(/구역$/, ""); // 기존 위치코드 Set (중복 체크용) @@ -640,7 +644,14 @@ export default function WarehouseManagementPage() { for (let row = cond.startRow; row <= cond.endRow; row++) { for (let level = 1; level <= cond.levels; level++) { const rowStr = String(row).padStart(2, "0"); - const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`; + // 세그먼트 순서에 따라 위치코드/위치명 조립 + const segCodeMap: Record = { zone: zoneCode, row: rowStr, level: String(level) }; + const segNameMap: Record = { + zone: `${zoneCode}${rackZoneLabel}`, + row: `${rowStr}${rackRowLabel}`, + level: `${level}${rackLevelLabel}`, + }; + const locationCode = `${whCode}-${rackSegmentOrder.map(s => segCodeMap[s]).join("-")}`; // 미리보기 내부 중복 제거 if (seen.has(locationCode)) continue; seen.add(locationCode); @@ -649,12 +660,12 @@ export default function WarehouseManagementPage() { duplicates.push(locationCode); continue; } - const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`; + const locationName = rackSegmentOrder.map(s => segNameMap[s]).join("-"); items.push({ location_code: locationCode, location_name: locationName, warehouse_code: whCode, - floor: floorLabel, + floor: "", zone: zoneLabel, row_num: String(row), level_num: String(level), @@ -924,10 +935,9 @@ export default function WarehouseManagementPage() { # 위치코드 위치명 - - 구역 - + 구역 + 유형 상태 @@ -960,10 +970,9 @@ export default function WarehouseManagementPage() { {loc.location_name} - {loc.floor} - {loc.zone} - {loc.row_num} + {loc.zone} {loc.level_num} + {loc.row_num} - {/* 층 */} -
- - - setLocationForm((prev) => ({ ...prev, floor: e.target.value })) - } - placeholder="층을 입력해주세요" - /> -
{/* 구역 */}
@@ -1296,7 +1294,7 @@ export default function WarehouseManagementPage() {

📍 기본 정보

-
+
-
- - {(locationCategoryOptions["floor"] || []).length > 0 ? ( - - ) : ( - setRackFloor(e.target.value)} - placeholder="예: B1, 1F, 2F" - /> - )} -
- {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} + {/* 위치명 형식 — 드래그로 세그먼트 순서 변경 가능 */}
-
- A - setRackZoneLabel(e.target.value)} - placeholder="구역" - className="h-8 w-20 text-xs" - /> - - 01 - setRackRowLabel(e.target.value)} - placeholder="열" - className="h-8 w-20 text-xs" - /> - - 1 - setRackLevelLabel(e.target.value)} - placeholder="단" - className="h-8 w-20 text-xs" - /> +

세그먼트를 드래그하여 순서를 변경할 수 있습니다

+
+ {rackSegmentOrder.map((seg, idx) => { + const config: Record void; placeholder: string; name: string }> = { + zone: { example: "A", label: rackZoneLabel, setLabel: setRackZoneLabel, placeholder: "구역", name: "구역" }, + row: { example: "01", label: rackRowLabel, setLabel: setRackRowLabel, placeholder: "열", name: "열" }, + level: { example: "1", label: rackLevelLabel, setLabel: setRackLevelLabel, placeholder: "단", name: "단" }, + }; + const c = config[seg]; + return ( + + {idx > 0 && -} +
{ + setDraggedSegment(seg); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverSegment(seg); + }} + onDragLeave={() => setDragOverSegment(null)} + onDrop={(e) => { + e.preventDefault(); + if (!draggedSegment || draggedSegment === seg) { + setDraggedSegment(null); + setDragOverSegment(null); + return; + } + setRackSegmentOrder((prev) => { + const next = [...prev]; + const fromIdx = next.indexOf(draggedSegment as any); + const toIdx = next.indexOf(seg as any); + next.splice(fromIdx, 1); + next.splice(toIdx, 0, draggedSegment as any); + return next; + }); + setDraggedSegment(null); + setDragOverSegment(null); + }} + onDragEnd={() => { setDraggedSegment(null); setDragOverSegment(null); }} + className={cn( + "flex items-center gap-1 rounded-md border px-2 py-1.5 cursor-grab active:cursor-grabbing transition-all", + draggedSegment === seg && "opacity-50 scale-95", + dragOverSegment === seg && draggedSegment !== seg && "ring-2 ring-primary border-primary bg-primary/5", + "bg-card hover:bg-accent/50" + )} + > + + {c.example} + c.setLabel(e.target.value)} + placeholder={c.placeholder} + className="h-6 w-14 text-[11px] px-1.5" + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + draggable={false} + /> +
+
+ ); + })}

- 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} - {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. + 예시: + {rackSegmentOrder.map((seg) => { + const ex: Record = { zone: `A${rackZoneLabel}`, row: `01${rackRowLabel}`, level: `1${rackLevelLabel}` }; + return ex[seg]; + }).join("-")} + + {" "}— 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다.

@@ -1590,10 +1611,9 @@ export default function WarehouseManagementPage() { No 위치코드 위치명 - 구역 - + 유형 비고 @@ -1606,10 +1626,9 @@ export default function WarehouseManagementPage() { {item.location_code} {item.location_name} - {item.floor} {item.zone} - {item.row_num} {item.level_num} + {item.row_num} {resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"}