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:
@@ -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 || "",
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.
|
||||
{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>
|
||||
);
|
||||
}
|
||||
2785
frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx
Normal file
2785
frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user