feat: Implement cutting plan management and work instruction modal

- Introduced a new cutting plan management page for COMPANY_9, allowing users to manage cutting plans effectively.
- Added a Work Instruction Apply Modal to facilitate the application of work instructions linked to cutting plans.
- Enhanced data handling by incorporating additional fields such as condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, and condition_plc_data in relevant controllers and database interactions.
- Updated UI components to support new features, including displaying batch numbers and item sizes in the work instruction page.

These changes aim to improve the efficiency and usability of cutting plan and work instruction management processes.
This commit is contained in:
kjs
2026-04-24 11:12:32 +09:00
parent c01166263b
commit 37ca354af9
8 changed files with 3240 additions and 25 deletions

View File

@@ -163,24 +163,29 @@ export async function getMaterialStatus(
bomParams.push(companyCode);
}
// inventory_unit은 카테고리 코드(CAT_xxx)로 저장됨 → category_values 조인으로 라벨 해상
const bomQuery = `
SELECT
b.item_code AS parent_item_code,
b.base_qty AS bom_base_qty,
bd.child_item_id,
bd.quantity AS bom_qty,
bd.unit AS bom_unit,
bd.loss_rate,
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit,
ii.inventory_unit AS material_inventory_unit,
COALESCE(cv_inv.value_label, ii.inventory_unit) AS material_inventory_unit,
COALESCE(ii.width::text, '') AS material_width,
COALESCE(ii.height::text, '') AS material_height,
COALESCE(ii.thickness::text, '') AS material_thickness
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
LEFT JOIN category_values cv_inv
ON cv_inv.table_name = 'item_info'
AND cv_inv.column_name = 'inventory_unit'
AND cv_inv.value_code = ii.inventory_unit
AND cv_inv.company_code = ii.company_code
AND cv_inv.is_active = true
WHERE b.item_code IN (${itemPlaceholders})
${bomCompanyCondition}
ORDER BY b.item_code, bd.seq_no
@@ -221,11 +226,7 @@ export async function getMaterialStatus(
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit:
bomRow.material_inventory_unit ||
bomRow.bom_unit ||
bomRow.material_unit ||
"EA",
unit: bomRow.material_inventory_unit || "",
requiredQty,
width: bomRow.material_width || "",
height: bomRow.material_height || "",

View File

@@ -463,7 +463,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply, created_date
selected_bom_items, process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date
@@ -493,6 +496,9 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply,
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
} = req.body;
if (!work_item_id || !content) {
@@ -516,8 +522,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields, selected_bom_items,
process_inspection_apply, equip_inspection_apply)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25)
RETURNING *
`;
@@ -545,6 +554,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
bomItemsJson,
process_inspection_apply || null,
equip_inspection_apply || null,
condition_unit || null,
condition_base_value || null,
condition_tolerance || null,
condition_auto_collect || null,
condition_plc_data || null,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
@@ -571,6 +585,9 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply,
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
} = req.body;
const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null;
@@ -594,6 +611,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
selected_bom_items = $17,
process_inspection_apply = $18,
equip_inspection_apply = $19,
condition_unit = $20,
condition_base_value = $21,
condition_tolerance = $22,
condition_auto_collect = $23,
condition_plc_data = $24,
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
@@ -619,6 +641,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
bomItemsJson,
process_inspection_apply || null,
equip_inspection_apply || null,
condition_unit ?? null,
condition_base_value ?? null,
condition_tolerance ?? null,
condition_auto_collect ?? null,
condition_plc_data ?? null,
]);
if (result.rowCount === 0) {
@@ -733,8 +760,11 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
duration_minutes, input_type, lookup_target, display_fields,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22)`,
[
companyCode,
workItemId,
@@ -753,6 +783,12 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
// 설비조건(equip_condition) 전용 5개 — TASK:ERP-015
detail.condition_unit || null,
detail.condition_base_value || null,
detail.condition_tolerance || null,
detail.condition_auto_collect || null,
detail.condition_plc_data || null,
]
);
}

View File

@@ -665,7 +665,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
process_inspection_apply, equip_inspection_apply
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -690,7 +692,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
process_inspection_apply, equip_inspection_apply
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -771,9 +775,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
for (const origDetail of origDetails.rows) {
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, userId]
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, userId]
);
}
}
@@ -838,9 +842,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, userId]
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, userId]
);
}
}

View File

@@ -74,7 +74,7 @@ export default function ItemInspectionInfoPage() {
const [saving, setSaving] = useState(false);
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string; size: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
@@ -136,6 +136,7 @@ export default function ItemInspectionInfoPage() {
name: r.item_name || "",
item_type: r.type || r.item_type || "",
unit: r.inventory_unit || "",
size: r.size || "",
})));
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
@@ -244,7 +245,7 @@ export default function ItemInspectionInfoPage() {
const resData = res.data?.data;
const rows = resData?.data || resData?.rows || [];
const cm = itemCatMapRef.current;
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "", size: r.size || "" })));
setItemTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -296,6 +297,7 @@ export default function ItemInspectionInfoPage() {
name: r.item_name,
item_type: cm["type"]?.[r.type] || r.type || "",
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
size: r.size || "",
}));
setCopyFilteredItems(list);
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
@@ -1244,17 +1246,19 @@ export default function ItemInspectionInfoPage() {
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm truncate max-w-[120px]" title={item.size}>{item.size}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>

View File

@@ -0,0 +1,376 @@
"use client";
/**
* 절단계획 → 작업지시 적용 모달
* jskim-node의 작업지시 모달 구조와 호환 (품목별 일정/설비/작업조/작업자 지정).
* 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달.
*/
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { CheckCircle2, ChevronsUpDown, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from "@/components/ui/select";
import {
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Table, TableHeader, TableRow, TableHead, TableBody, TableCell,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
previewWorkInstructionNo, saveWorkInstruction,
getEquipmentList, getEmployeeList,
} from "@/lib/api/workInstruction";
// ─── 공용 다중선택 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) {
return value.map((v) => options.find((o) => o.value === v)?.label || v).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 interface WorkInstructionApplyItem {
itemCode: string;
itemName: string;
spec?: string;
qty: number;
remark?: string;
sourceTable?: string;
sourceId?: string;
// 품목별 일정/설비/작업조/작업자
startDate?: string;
endDate?: string;
equipmentIds?: string[];
workTeams?: string[];
workers?: string[];
}
export interface WorkInstructionApplyModalProps {
open: boolean;
onOpenChange: (v: boolean) => void;
initialItems: WorkInstructionApplyItem[];
batchNo?: string | null;
cuttingPlanId?: number | null;
onSaved?: (result: { id: string; workInstructionNo: string }) => void;
}
export default function WorkInstructionApplyModal({
open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved,
}: WorkInstructionApplyModalProps) {
const [wiNo, setWiNo] = useState("");
const [status, setStatus] = useState("일반");
const [remark, setRemark] = useState("");
const [items, setItems] = useState<WorkInstructionApplyItem[]>([]);
const [saving, setSaving] = useState(false);
const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]);
// 모달 오픈 시 초기화 + 옵션 로드
useEffect(() => {
if (!open) return;
const today = new Date().toISOString().slice(0, 10);
setItems(initialItems.map((x) => ({
...x,
startDate: x.startDate || today,
endDate: x.endDate || "",
equipmentIds: x.equipmentIds || [],
workTeams: x.workTeams || [],
workers: x.workers || [],
})));
setStatus("일반");
setRemark("");
previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {});
getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {});
getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {});
}, [open, initialItems]);
const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]);
const handleSave = async () => {
if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; }
setSaving(true);
try {
// 하위호환: 마스터에는 첫 품목의 대표값을 실음 (jskim 스타일)
const first = items[0];
const payload = {
status,
startDate: first?.startDate || "",
endDate: first?.endDate || "",
equipmentId: first?.equipmentIds?.[0] || "",
workTeam: first?.workTeams?.[0] || "",
worker: first?.workers?.[0] || "",
remark,
routing: null,
batchNo: batchNo || null,
cuttingPlanId: cuttingPlanId ?? null,
items: items.map((i) => ({
itemNumber: i.itemCode, itemCode: i.itemCode, partCode: i.itemCode,
qty: String(i.qty), remark: i.remark || "",
sourceTable: i.sourceTable || "cutting_plan",
sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""),
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) { toast.error(r.message || "저장 실패"); return; }
toast.success(`작업지시 ${r.data?.workInstructionNo || wiNo} 등록 완료`);
onOpenChange(false);
onSaved?.(r.data);
} catch (e: any) {
toast.error(e?.message || "저장 실패");
} finally {
setSaving(false);
}
};
const equipmentSelectOptions = useMemo(
() => equipmentOptions.map((eq) => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code })),
[equipmentOptions]
);
const workerSelectOptions = useMemo(
() => workerOptions.map((w) => ({ value: w.user_id, label: w.user_name, sub: w.dept_name || undefined })),
[workerOptions]
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
&apos; &apos; .
{batchNo ? <span className="ml-2 text-primary font-medium"> {batchNo}</span> : null}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<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>
<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={wiNo} 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={status} onValueChange={setStatus}>
<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 className="h-9" placeholder="비고를 입력해주세요" value={remark} onChange={(e) => setRemark(e.target.value)} />
</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 className="min-w-[1700px]">
<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-[130px] 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="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-[80px] 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-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-mono text-primary">{batchNo || "-"}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>
{item.itemName || "-"}
</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell>
<Input type="number" className="h-7 text-[13px] w-20"
value={item.qty}
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]"
value={item.startDate || ""}
onChange={(e) => setItems((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) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentSelectOptions}
value={item.equipmentIds || []}
onChange={(next) => setItems((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) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={workerSelectOptions}
value={item.workers || []}
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
placeholder="작업자 선택"
searchable
emptyMessage="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell>
<Input className="h-7 text-[13px]" placeholder="비고"
value={item.remark || ""}
onChange={(e) => setItems((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={() => setItems((prev) => prev.filter((_, i) => i !== idx))}>
<X className="w-3 h-3 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={13} className="text-center text-muted-foreground text-[12px] py-6">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={!canSave || saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -647,6 +647,7 @@ export default function WorkInstructionPage() {
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "batch_no", label: "배치번호", width: "w-[130px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v ? <span className="font-mono text-primary">{v}</span> : <span className="text-muted-foreground">-</span>) : "" },
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return null;
@@ -911,6 +912,9 @@ export default function WorkInstructionPage() {
<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>
{editOrder?.batch_no ? (
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
) : null}
<TableHead className="w-[110px] 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>
@@ -928,10 +932,13 @@ export default function WorkInstructionPage() {
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={editOrder?.batch_no ? 15 : 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>
{editOrder?.batch_no ? (
<TableCell className="text-[13px] font-mono text-primary">{editOrder.batch_no}</TableCell>
) : null}
<TableCell className="text-[13px] font-medium">{item.itemCode}</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>

View File

@@ -340,6 +340,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_9/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
@@ -572,6 +573,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/COMPANY_9/production/work-instruction": () => import("@/app/(main)/COMPANY_9/production/work-instruction/page"),
"/COMPANY_9/production/plan-management": () => import("@/app/(main)/COMPANY_9/production/plan-management/page"),
"/COMPANY_9/production/bom": () => import("@/app/(main)/COMPANY_9/production/bom/page"),
"/COMPANY_9/production/cutting-plan": () => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"),
"/COMPANY_9/equipment/info": () => import("@/app/(main)/COMPANY_9/equipment/info/page"),
"/COMPANY_9/equipment/plc-settings": () => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"),
"/COMPANY_9/monitoring/production": () => import("@/app/(main)/COMPANY_9/monitoring/production/page"),