Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into mhkim-node

This commit is contained in:
kmh
2026-04-23 12:03:31 +09:00
15 changed files with 1626 additions and 429 deletions
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -634,38 +773,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -674,7 +808,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -696,6 +830,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -716,7 +884,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -725,48 +893,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -809,6 +976,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -638,38 +777,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -678,7 +812,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -700,6 +834,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -720,7 +888,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -729,48 +897,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -813,6 +980,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -634,38 +773,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -674,7 +808,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -696,6 +830,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -716,7 +884,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -725,48 +893,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -809,6 +976,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -650,38 +789,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -690,7 +824,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -712,6 +846,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -732,7 +900,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -741,48 +909,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -825,6 +992,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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<string | null>(null);
const [dragOverSegment, setDragOverSegment] = useState<string | null>(null);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -199,7 +203,7 @@ export default function WarehouseManagementPage() {
setCategoryOptions(whOpts);
const locOpts: Record<string, { code: string; label: string }[]> = {};
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<string, string> = { zone: zoneCode, row: rowStr, level: String(level) };
const segNameMap: Record<string, string> = {
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() {
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
@@ -960,10 +970,9 @@ export default function WarehouseManagementPage() {
<TableCell className="truncate max-w-[120px]">
{loc.location_name}
</TableCell>
<TableCell className="text-center">{loc.floor}</TableCell>
<TableCell>{loc.zone}</TableCell>
<TableCell className="text-center">{loc.row_num}</TableCell>
<TableCell className="text-center">{loc.zone}</TableCell>
<TableCell className="text-center">{loc.level_num}</TableCell>
<TableCell className="text-center">{loc.row_num}</TableCell>
<TableCell>
<Badge
variant={getTypeVariant(loc.location_type)}
@@ -1174,17 +1183,6 @@ export default function WarehouseManagementPage() {
placeholder="위치명을 입력해주세요"
/>
</div>
{/* 층 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
value={locationForm.floor || ""}
onChange={(e) =>
setLocationForm((prev) => ({ ...prev, floor: e.target.value }))
}
placeholder="층을 입력해주세요"
/>
</div>
{/* 구역 */}
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
@@ -1296,7 +1294,7 @@ export default function WarehouseManagementPage() {
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
📍
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
@@ -1306,31 +1304,6 @@ export default function WarehouseManagementPage() {
disabled
/>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
{(locationCategoryOptions["floor"] || []).length > 0 ? (
<Select value={rackFloor} onValueChange={setRackFloor}>
<SelectTrigger>
<SelectValue placeholder="층 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["floor"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rackFloor}
onChange={(e) => setRackFloor(e.target.value)}
placeholder="예: B1, 1F, 2F"
/>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
@@ -1515,35 +1488,83 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
{/* 위치명 형식 — 드래그로 세그먼트 순서 변경 가능 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
<p className="text-[10px] text-muted-foreground"> </p>
<div className="flex items-center gap-1.5 text-xs flex-wrap">
{rackSegmentOrder.map((seg, idx) => {
const config: Record<string, { example: string; label: string; setLabel: (v: string) => 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 (
<React.Fragment key={seg}>
{idx > 0 && <span className="font-mono text-muted-foreground select-none">-</span>}
<div
draggable
onDragStart={(e) => {
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"
)}
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
<span className="font-mono text-muted-foreground font-semibold text-[11px] shrink-0">{c.example}</span>
<Input
value={c.label}
onChange={(e) => 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}
/>
</div>
</React.Fragment>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
: <span className="font-mono font-semibold">
{rackSegmentOrder.map((seg) => {
const ex: Record<string, string> = { zone: `A${rackZoneLabel}`, row: `01${rackRowLabel}`, level: `1${rackLevelLabel}` };
return ex[seg];
}).join("-")}
</span>
{" "} , .
</p>
</div>
@@ -1590,10 +1611,9 @@ export default function WarehouseManagementPage() {
<TableHead className="w-10 text-center text-[11px] font-bold text-muted-foreground">No</TableHead>
<TableHead className="text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground"></TableHead>
</TableRow>
@@ -1606,10 +1626,9 @@ export default function WarehouseManagementPage() {
</TableCell>
<TableCell className="font-mono">{item.location_code}</TableCell>
<TableCell>{item.location_name}</TableCell>
<TableCell className="text-center">{item.floor}</TableCell>
<TableCell className="text-center">{item.zone}</TableCell>
<TableCell className="text-center">{item.row_num}</TableCell>
<TableCell className="text-center">{item.level_num}</TableCell>
<TableCell className="text-center">{item.row_num}</TableCell>
<TableCell>
{resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"}
</TableCell>
@@ -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;
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -634,38 +773,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -674,7 +808,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -696,6 +830,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -716,7 +884,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -725,48 +893,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -809,6 +976,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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;
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={e => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={e => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
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<SelectedItem, "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers">;
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단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
@@ -634,38 +773,33 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
@@ -674,7 +808,7 @@ export default function WorkInstructionPage() {
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -696,6 +830,40 @@ export default function WorkInstructionPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -716,7 +884,7 @@ export default function WorkInstructionPage() {
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
@@ -725,48 +893,47 @@ export default function WorkInstructionPage() {
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
{/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<Table className="min-w-[1500px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
@@ -809,6 +976,40 @@ export default function WorkInstructionPage() {
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.startDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]" value={item.endDate || ""} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentOptions.map(eq => ({ 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="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={employeeOptions.map(emp => ({ 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="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
@@ -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<string>();
const seenChildForLegacy = new Set<string>();
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"
>
<Checkbox
checked={bomChecked.has(mat.child_item_id)}
checked={bomChecked.has(mat.id)}
onCheckedChange={(checked) => {
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;
});
}}