jskim-node #423

Merged
kjs merged 27 commits from jskim-node into main 2026-03-20 16:10:33 +09:00
9 changed files with 737 additions and 193 deletions
Showing only changes of commit 199fa60ef5 - Show all commits

View File

@ -420,3 +420,44 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// BOM 구성 자재 조회 (품목코드 기반)
// ═══════════════════════════════════════════
export async function getBomMaterials(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
if (!itemCode) {
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
}
const query = `
SELECT
bd.id,
bd.child_item_id,
bd.quantity,
bd.unit as detail_unit,
bd.process_type,
i.item_name as child_item_name,
i.item_number as child_item_code,
i.type as child_item_type,
i.unit as item_unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code
WHERE b.item_code = $1 AND b.company_code = $2
ORDER BY bd.seq_no ASC, bd.created_date ASC
`;
const result = await pool.query(query, [itemCode, companyCode]);
logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("BOM 자재 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -39,4 +39,7 @@ router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
// BOM 구성 자재 조회
router.get("/bom-materials/:itemCode", ctrl.getBomMaterials);
export default router;

View File

@ -274,3 +274,26 @@ export async function saveRoutingDetails(
return { success: false, message: e.message };
}
}
// ═══ BOM 구성 자재 조회 ═══
export interface BomMaterial {
id: string;
child_item_id: string;
quantity: string;
detail_unit: string | null;
process_type: string | null;
child_item_name: string | null;
child_item_code: string | null;
child_item_type: string | null;
item_unit: string | null;
}
export async function getBomMaterials(itemCode: string): Promise<ApiResponse<BomMaterial[]>> {
try {
const res = await apiClient.get(`${BASE}/bom-materials/${encodeURIComponent(itemCode)}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}

View File

@ -208,6 +208,7 @@ export function ProcessWorkStandardComponent({
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
detailTypes={config.detailTypes}
readonly={config.readonly}
selectedItemCode={selection.itemCode || undefined}
onSelectWorkItem={handleSelectWorkItem}
onAddWorkItem={handleAddWorkItem}
onEditWorkItem={handleEditWorkItem}

View File

@ -1,10 +1,11 @@
"use client";
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import React, { useState, useEffect, useCallback } from "react";
import { Search, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -22,6 +23,7 @@ import {
} from "@/components/ui/dialog";
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
import { InspectionStandardLookup } from "./InspectionStandardLookup";
import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo";
interface DetailFormModalProps {
open: boolean;
@ -30,24 +32,53 @@ interface DetailFormModalProps {
detailTypes: DetailTypeDefinition[];
editData?: WorkItemDetail | null;
mode: "add" | "edit";
selectedItemCode?: string;
}
const LOOKUP_TARGETS = [
{ value: "equipment", label: "설비정보" },
{ value: "material", label: "자재정보" },
{ value: "worker", label: "작업자정보" },
{ value: "tool", label: "공구정보" },
{ value: "document", label: "문서정보" },
];
const INPUT_TYPES = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "textarea", label: "장문 텍스트" },
{ value: "select", label: "선택형" },
];
const UNIT_OPTIONS = [
"mm", "cm", "m", "μm", "℃", "℉", "bar", "Pa", "MPa", "psi",
"RPM", "kg", "N", "N·m", "m/s", "m/min", "A", "V", "kW", "%",
"L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L",
];
const PLC_DATA_OPTIONS = [
{ value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" },
{ value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" },
{ value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" },
{ value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" },
{ value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" },
{ value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" },
{ value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" },
{ value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" },
{ value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" },
{ value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" },
];
const PLC_PRODUCTION_OPTIONS = {
work_qty: [
{ value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" },
{ value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" },
{ value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" },
],
defect_qty: [
{ value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" },
{ value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" },
{ value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" },
],
good_qty: [
{ value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" },
{ value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" },
{ value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" },
],
};
export function DetailFormModal({
open,
onClose,
@ -55,10 +86,35 @@ export function DetailFormModal({
detailTypes,
editData,
mode,
selectedItemCode,
}: DetailFormModalProps) {
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
const [bomMaterials, setBomMaterials] = useState<BomMaterial[]>([]);
const [bomLoading, setBomLoading] = useState(false);
const [bomChecked, setBomChecked] = useState<Set<string>>(new Set());
const loadBomMaterials = useCallback(async () => {
if (!selectedItemCode) {
setBomMaterials([]);
return;
}
setBomLoading(true);
try {
const res = await getBomMaterials(selectedItemCode);
if (res.success && res.data) {
setBomMaterials(res.data);
setBomChecked(new Set(res.data.map((m) => m.child_item_id)));
} else {
setBomMaterials([]);
}
} catch {
setBomMaterials([]);
} finally {
setBomLoading(false);
}
}, [selectedItemCode]);
useEffect(() => {
if (open) {
@ -86,6 +142,12 @@ export function DetailFormModal({
}
}, [open, mode, editData, detailTypes]);
useEffect(() => {
if (open && formData.detail_type === "material_input") {
loadBomMaterials();
}
}, [open, formData.detail_type, loadBomMaterials]);
const updateField = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
@ -108,17 +170,33 @@ export function DetailFormModal({
const type = formData.detail_type;
if (type === "check" && !formData.content?.trim()) return;
if (type === "inspect" && !formData.content?.trim()) return;
if (type === "checklist" && !formData.content?.trim()) return;
if (type === "inspection") {
if (!formData.process_inspection_apply) return;
if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return;
}
if (type === "procedure" && !formData.content?.trim()) return;
if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return;
if (type === "equip_inspection" && !formData.equip_inspection_apply) return;
if (type === "equip_condition" && !formData.content?.trim()) return;
const submitData = { ...formData };
if (type === "info" && !submitData.content?.trim()) {
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
submitData.content = `${targetLabel} 조회`;
// content 자동 설정 (UI에서 직접 입력이 없는 유형들)
if (type === "inspection" && submitData.process_inspection_apply === "apply") {
submitData.content = submitData.content || "품목별 검사정보 (자동 연동)";
}
if (type === "lookup") {
submitData.content = submitData.content || "품목 등록 문서 (자동 연동)";
}
if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") {
submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)";
}
if (type === "production_result") {
submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량";
}
if (type === "material_input") {
submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)";
}
onSubmit(submitData);
@ -130,7 +208,7 @@ export function DetailFormModal({
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{mode === "add" ? "추가" : "수정"}
@ -149,12 +227,11 @@ export function DetailFormModal({
<Select
value={currentType}
onValueChange={(v) => {
updateField("detail_type", v);
setSelectedInspection(null);
setFormData((prev) => ({
setFormData({
detail_type: v,
is_required: prev.is_required || "Y",
}));
is_required: formData.is_required || "Y",
});
}}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
@ -170,9 +247,8 @@ export function DetailFormModal({
</Select>
</div>
{/* 체크리스트 */}
{currentType === "check" && (
<>
{/* ============ 체크리스트 ============ */}
{currentType === "checklist" && (
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
@ -184,68 +260,55 @@ export function DetailFormModal({
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 검사항목 */}
{currentType === "inspect" && (
{/* ============ 검사항목 ============ */}
{currentType === "inspection" && (
<>
{/* 공정검사 적용 여부 */}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
<span className="text-destructive">*</span>
</Label>
<div className="mt-1 flex gap-2">
<Select value="_placeholder" disabled>
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
<SelectValue>
{selectedInspection
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
: "검사기준을 선택하세요"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="_placeholder"></SelectItem>
</SelectContent>
</Select>
<Button
variant="secondary"
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
onClick={() => setInspectionLookupOpen(true)}
>
<Search className="h-3.5 w-3.5" />
</Button>
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
name="processInspection"
checked={formData.process_inspection_apply === "apply"}
onChange={() => updateField("process_inspection_apply", "apply")}
className="h-4 w-4 accent-primary"
/>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
name="processInspection"
checked={formData.process_inspection_apply === "none"}
onChange={() => updateField("process_inspection_apply", "none")}
className="h-4 w-4 accent-primary"
/>
</label>
</div>
</div>
{selectedInspection && (
<div className="rounded border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
{/* 적용 시: 품목별 검사정보 자동 연동 안내 */}
{formData.process_inspection_apply === "apply" && (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
<p className="text-xs font-semibold text-sky-800">
( )
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<p>
<strong>:</strong> {selectedInspection.inspection_code}
<p className="mt-1 text-[11px] text-muted-foreground">
.
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_item}
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_method || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.unit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.lower_limit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.upper_limit || "-"}
</p>
</div>
</div>
)}
{/* 미적용 시: 수동 입력 */}
{formData.process_inspection_apply === "none" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
@ -257,7 +320,6 @@ export function DetailFormModal({
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"> </Label>
@ -270,39 +332,82 @@ export function DetailFormModal({
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
<Select
value={formData.unit || ""}
onChange={(e) => updateField("unit", e.target.value)}
placeholder="예: mm"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
onValueChange={(v) => updateField("unit", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="단위 선택" />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((u) => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기준값 ± 오차범위 */}
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
type="number"
step="any"
value={formData.base_value || ""}
onChange={(e) => updateField("base_value", e.target.value)}
placeholder="기준값"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<span className="text-sm font-semibold text-muted-foreground">±</span>
<div className="flex-1">
<Input
type="number"
step="any"
value={formData.tolerance || ""}
onChange={(e) => updateField("tolerance", e.target.value)}
placeholder="오차범위"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.lower_limit || ""}
onChange={(e) => updateField("lower_limit", e.target.value)}
placeholder="예: 7.95"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.upper_limit || ""}
onChange={(e) => updateField("upper_limit", e.target.value)}
placeholder="예: 8.05"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
{/* 자동수집 */}
<div className="mt-3 flex items-center gap-3 border-t pt-3">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium sm:text-sm">
<Checkbox
checked={formData.auto_collect === "Y"}
onCheckedChange={(checked) => {
updateField("auto_collect", checked ? "Y" : "N");
if (!checked) updateField("plc_data", "");
}}
/>
</label>
<Select
value={formData.plc_data || ""}
onValueChange={(v) => updateField("plc_data", v)}
disabled={formData.auto_collect !== "Y"}
>
<SelectTrigger className={`h-8 flex-1 text-xs sm:h-10 sm:text-sm ${formData.auto_collect !== "Y" ? "opacity-50" : ""}`}>
<SelectValue placeholder="수집데이터 선택" />
</SelectTrigger>
<SelectContent>
{PLC_DATA_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
)}
</>
)}
{/* 작업절차 */}
{/* ============ 작업절차 ============ */}
{currentType === "procedure" && (
<>
<div>
@ -322,10 +427,7 @@ export function DetailFormModal({
type="number"
value={formData.duration_minutes ?? ""}
onChange={(e) =>
updateField(
"duration_minutes",
e.target.value ? Number(e.target.value) : undefined
)
updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)
}
placeholder="예: 5"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
@ -334,7 +436,7 @@ export function DetailFormModal({
</>
)}
{/* 직접입력 */}
{/* ============ 직접입력 ============ */}
{currentType === "input" && (
<>
<div>
@ -359,9 +461,7 @@ export function DetailFormModal({
</SelectTrigger>
<SelectContent>
{INPUT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
@ -369,41 +469,367 @@ export function DetailFormModal({
</>
)}
{/* 정보조회 */}
{currentType === "info" && (
{/* ============ 문서참조 ============ */}
{currentType === "lookup" && (
<>
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2.5">
<span className="text-sm">📄</span>
<span className="text-xs text-blue-800">
.
</span>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1 rounded-lg border p-3">
<p className="text-center text-xs text-muted-foreground">
.
</p>
</div>
</div>
</>
)}
{/* ============ 설비점검 ============ */}
{currentType === "equip_inspection" && (
<>
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2.5">
<span className="text-sm">🏭</span>
<span className="text-xs text-green-800">
.
</span>
</div>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
<span className="text-destructive">*</span>
</Label>
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
name="equipInspApply"
checked={formData.equip_inspection_apply === "apply"}
onChange={() => updateField("equip_inspection_apply", "apply")}
className="h-4 w-4 accent-primary"
/>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
name="equipInspApply"
checked={formData.equip_inspection_apply === "none"}
onChange={() => updateField("equip_inspection_apply", "none")}
className="h-4 w-4 accent-primary"
/>
</label>
</div>
</div>
{/* 적용 시: 설비 점검항목 자동 연동 */}
{formData.equip_inspection_apply === "apply" && (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
<p className="text-xs font-semibold text-sky-800">
( )
</p>
<p className="mt-1 text-[11px] text-muted-foreground">
.
</p>
</div>
)}
</>
)}
{/* ============ 설비조건 ============ */}
{currentType === "equip_condition" && (
<>
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2.5">
<span className="text-sm">🏭</span>
<span className="text-xs text-green-800">
.
</span>
</div>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<div className="mt-2 rounded-lg border bg-muted/30 p-3 space-y-3">
{/* 조건명 + 단위 */}
<div className="flex gap-2">
<div className="flex-[1.2]">
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="조건명 (예: 온도, 압력, RPM)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="flex-[0.6]">
<Select
value={formData.lookup_target || ""}
onValueChange={(v) => updateField("lookup_target", v)}
value={formData.condition_unit || ""}
onValueChange={(v) => updateField("condition_unit", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="단위" />
</SelectTrigger>
<SelectContent>
{LOOKUP_TARGETS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
{UNIT_OPTIONS.map((u) => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
</div>
{/* 기준값 ± 오차범위 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
value={formData.display_fields || ""}
onChange={(e) => updateField("display_fields", e.target.value)}
placeholder="예: 설비명, 설비코드"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
type="number"
step="any"
value={formData.condition_base_value || ""}
onChange={(e) => updateField("condition_base_value", e.target.value)}
placeholder="기준값"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<span className="text-sm font-semibold text-muted-foreground">±</span>
<div className="flex-1">
<Input
type="number"
step="any"
value={formData.condition_tolerance || ""}
onChange={(e) => updateField("condition_tolerance", e.target.value)}
placeholder="오차범위"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 자동수집 */}
<div className="flex items-center gap-3 border-t pt-3">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium sm:text-sm">
<Checkbox
checked={formData.condition_auto_collect === "Y"}
onCheckedChange={(checked) => {
updateField("condition_auto_collect", checked ? "Y" : "N");
if (!checked) updateField("condition_plc_data", "");
}}
/>
</label>
<Select
value={formData.condition_plc_data || ""}
onValueChange={(v) => updateField("condition_plc_data", v)}
disabled={formData.condition_auto_collect !== "Y"}
>
<SelectTrigger className={`h-8 flex-1 text-xs sm:h-10 sm:text-sm ${formData.condition_auto_collect !== "Y" ? "opacity-50" : ""}`}>
<SelectValue placeholder="수집데이터 선택" />
</SelectTrigger>
<SelectContent>
{PLC_DATA_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</>
)}
{/* ============ 실적등록 ============ */}
{currentType === "production_result" && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-2 rounded-lg border bg-muted/30 p-3 space-y-0 divide-y">
{/* 작업수량 */}
<div className="flex items-center justify-between py-3 first:pt-0">
<div className="flex-1">
<span className="text-sm font-semibold">📦 </span>
<span className="ml-2 text-[11px] text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-3">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={formData.work_qty_auto_collect === "Y"}
onCheckedChange={(checked) => {
updateField("work_qty_auto_collect", checked ? "Y" : "N");
if (!checked) updateField("work_qty_plc_data", "");
}}
/>
</label>
<Select
value={formData.work_qty_plc_data || ""}
onValueChange={(v) => updateField("work_qty_plc_data", v)}
disabled={formData.work_qty_auto_collect !== "Y"}
>
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.work_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
<SelectValue placeholder="수집데이터 선택" />
</SelectTrigger>
<SelectContent>
{PLC_PRODUCTION_OPTIONS.work_qty.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 불량수량 */}
<div className="flex items-center justify-between py-3">
<div className="flex-1">
<span className="text-sm font-semibold">🚫 </span>
<span className="ml-2 text-[11px] text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-3">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={formData.defect_qty_auto_collect === "Y"}
onCheckedChange={(checked) => {
updateField("defect_qty_auto_collect", checked ? "Y" : "N");
if (!checked) updateField("defect_qty_plc_data", "");
}}
/>
</label>
<Select
value={formData.defect_qty_plc_data || ""}
onValueChange={(v) => updateField("defect_qty_plc_data", v)}
disabled={formData.defect_qty_auto_collect !== "Y"}
>
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.defect_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
<SelectValue placeholder="수집데이터 선택" />
</SelectTrigger>
<SelectContent>
{PLC_PRODUCTION_OPTIONS.defect_qty.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 양품수량 */}
<div className="flex items-center justify-between py-3 last:pb-0">
<div className="flex-1">
<span className="text-sm font-semibold"> </span>
<span className="ml-2 text-[11px] text-muted-foreground"> - </span>
</div>
<div className="flex items-center gap-3">
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={formData.good_qty_auto_collect === "Y"}
onCheckedChange={(checked) => {
updateField("good_qty_auto_collect", checked ? "Y" : "N");
if (!checked) updateField("good_qty_plc_data", "");
}}
/>
</label>
<Select
value={formData.good_qty_plc_data || ""}
onValueChange={(v) => updateField("good_qty_plc_data", v)}
disabled={formData.good_qty_auto_collect !== "Y"}
>
<SelectTrigger className={`h-8 w-[180px] text-xs ${formData.good_qty_auto_collect !== "Y" ? "opacity-50" : ""}`}>
<SelectValue placeholder="수집데이터 선택" />
</SelectTrigger>
<SelectContent>
{PLC_PRODUCTION_OPTIONS.good_qty.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
)}
{/* ============ 자재투입 ============ */}
{currentType === "material_input" && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-2 rounded-lg border border-sky-200 bg-sky-50 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold text-sky-800">
📦 BOM ( )
</p>
<span className="text-[11px] text-muted-foreground">
{bomMaterials.length > 0 ? `${bomMaterials.length}` : ""}
</span>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border bg-white">
{bomLoading ? (
<div className="flex items-center justify-center gap-2 py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">BOM ...</span>
</div>
) : !selectedItemCode ? (
<div className="py-5 text-center text-xs text-muted-foreground">
.
</div>
) : bomMaterials.length === 0 ? (
<div className="py-5 text-center text-xs text-muted-foreground">
BOM .
</div>
) : (
bomMaterials.map((mat) => {
const typeColor =
mat.child_item_type === "원자재" ? "#16a34a"
: mat.child_item_type === "반제품" ? "#2563eb"
: "#6b7280";
return (
<div
key={mat.id}
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)}
onCheckedChange={(checked) => {
setBomChecked((prev) => {
const next = new Set(prev);
if (checked) next.add(mat.child_item_id);
else next.delete(mat.child_item_id);
return next;
});
}}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{mat.child_item_type && (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ backgroundColor: typeColor }}
>
{mat.child_item_type}
</span>
)}
<span className="text-[11px] text-muted-foreground">
{mat.child_item_code || "-"}
</span>
<span className="text-xs font-medium">
{mat.child_item_name || "(이름 없음)"}
</span>
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""}
{mat.process_type ? ` | 공정: ${mat.process_type}` : ""}
</div>
</div>
</div>
);
})
)}
</div>
</div>
</div>
)}
{/* 필수 여부 (모든 유형 공통) */}
{currentType && (
<div>

View File

@ -13,6 +13,7 @@ interface WorkItemDetailListProps {
details: WorkItemDetail[];
detailTypes: DetailTypeDefinition[];
readonly?: boolean;
selectedItemCode?: string;
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
onDeleteDetail: (id: string) => void;
@ -23,6 +24,7 @@ export function WorkItemDetailList({
details,
detailTypes,
readonly,
selectedItemCode,
onCreateDetail,
onUpdateDetail,
onDeleteDetail,
@ -66,11 +68,12 @@ export function WorkItemDetailList({
const getContentSummary = (detail: WorkItemDetail): string => {
const type = detail.detail_type;
if (type === "inspect" && detail.inspection_code) {
if (type === "inspection") {
if (detail.process_inspection_apply === "apply") return "품목별 검사정보 (자동 연동)";
const parts = [detail.content];
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
if (detail.lower_limit || detail.upper_limit) {
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
if (detail.base_value) {
parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`);
}
return parts.join(" ");
}
@ -83,20 +86,24 @@ export function WorkItemDetailList({
number: "숫자",
date: "날짜",
textarea: "장문",
select: "선택형",
};
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
}
if (type === "info" && detail.lookup_target) {
const targetMap: Record<string, string> = {
equipment: "설비정보",
material: "자재정보",
worker: "작업자정보",
tool: "공구정보",
document: "문서정보",
};
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
if (type === "lookup") return "품목 등록 문서 (자동 연동)";
if (type === "equip_inspection") {
return detail.equip_inspection_apply === "apply"
? "설비 점검항목 (설비정보 연동)"
: detail.content || "설비점검";
}
if (type === "equip_condition") {
const parts = [detail.content];
if (detail.condition_base_value) {
parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`);
}
return parts.join(" ");
}
if (type === "production_result") return "작업수량 / 불량수량 / 양품수량";
if (type === "material_input") return "BOM 구성 자재 (자동 연동)";
return detail.content || "-";
};
@ -214,6 +221,7 @@ export function WorkItemDetailList({
detailTypes={detailTypes}
editData={editTarget}
mode={modalMode}
selectedItemCode={selectedItemCode}
/>
</div>
);

View File

@ -20,6 +20,7 @@ interface WorkPhaseSectionProps {
selectedWorkItemDetails: WorkItemDetail[];
detailTypes: DetailTypeDefinition[];
readonly?: boolean;
selectedItemCode?: string;
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
onAddWorkItem: (phase: string) => void;
onEditWorkItem: (item: WorkItem) => void;
@ -36,6 +37,7 @@ export function WorkPhaseSection({
selectedWorkItemDetails,
detailTypes,
readonly,
selectedItemCode,
onSelectWorkItem,
onAddWorkItem,
onEditWorkItem,
@ -107,6 +109,7 @@ export function WorkPhaseSection({
details={selectedWorkItemDetails}
detailTypes={detailTypes}
readonly={readonly}
selectedItemCode={selectedItemCode}
onCreateDetail={(data) =>
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
}

View File

@ -23,10 +23,15 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
],
detailTypes: [
{ value: "check", label: "체크리스트" },
{ value: "inspect", label: "검사항목" },
{ value: "checklist", label: "체크리스트" },
{ value: "inspection", label: "검사항목" },
{ value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "lookup", label: "문서참조" },
{ value: "equip_inspection", label: "설비점검" },
{ value: "equip_condition", label: "설비조건" },
{ value: "production_result", label: "실적등록" },
{ value: "material_input", label: "자재투입" },
],
splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택",

View File

@ -91,19 +91,53 @@ export interface WorkItemDetail {
sort_order: number;
remark?: string;
created_date?: string;
// 검사항목 전용
// 검사항목(inspection) 전용
process_inspection_apply?: string; // "apply" | "none"
inspection_code?: string;
inspection_method?: string;
unit?: string;
base_value?: string;
tolerance?: string;
lower_limit?: string;
upper_limit?: string;
// 작업절차 전용
auto_collect?: string; // "Y" | "N"
plc_data?: string;
// 작업절차(procedure) 전용
duration_minutes?: number;
// 직접입력 전용
// 직접입력(input) 전용
input_type?: string;
// 정보조회 전용
// 문서참조(lookup) 전용
lookup_target?: string;
display_fields?: string;
// 설비점검(equip_inspection) 전용
equip_inspection_apply?: string; // "apply" | "none"
// 설비조건(equip_condition) 전용
condition_name?: string;
condition_unit?: string;
condition_base_value?: string;
condition_tolerance?: string;
condition_auto_collect?: string;
condition_plc_data?: string;
// 실적등록(production_result) 전용
work_qty_auto_collect?: string;
work_qty_plc_data?: string;
defect_qty_auto_collect?: string;
defect_qty_plc_data?: string;
good_qty_auto_collect?: string;
good_qty_plc_data?: string;
// 자재투입(material_input) 전용 - BOM 자동연동
material_code?: string;
material_name?: string;
quantity?: string;
material_unit?: string;
}
export interface InspectionStandard {