jskim-node #423
|
|
@ -420,3 +420,44 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||||
return res.status(500).json({ success: false, message: error.message });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,7 @@ router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
|
||||||
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
|
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
|
||||||
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
|
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
|
||||||
|
|
||||||
|
// BOM 구성 자재 조회
|
||||||
|
router.get("/bom-materials/:itemCode", ctrl.getBomMaterials);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -274,3 +274,26 @@ export async function saveRoutingDetails(
|
||||||
return { success: false, message: e.message };
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ export function ProcessWorkStandardComponent({
|
||||||
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
||||||
detailTypes={config.detailTypes}
|
detailTypes={config.detailTypes}
|
||||||
readonly={config.readonly}
|
readonly={config.readonly}
|
||||||
|
selectedItemCode={selection.itemCode || undefined}
|
||||||
onSelectWorkItem={handleSelectWorkItem}
|
onSelectWorkItem={handleSelectWorkItem}
|
||||||
onAddWorkItem={handleAddWorkItem}
|
onAddWorkItem={handleAddWorkItem}
|
||||||
onEditWorkItem={handleEditWorkItem}
|
onEditWorkItem={handleEditWorkItem}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -22,6 +23,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||||
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||||
|
import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo";
|
||||||
|
|
||||||
interface DetailFormModalProps {
|
interface DetailFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -30,24 +32,53 @@ interface DetailFormModalProps {
|
||||||
detailTypes: DetailTypeDefinition[];
|
detailTypes: DetailTypeDefinition[];
|
||||||
editData?: WorkItemDetail | null;
|
editData?: WorkItemDetail | null;
|
||||||
mode: "add" | "edit";
|
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 = [
|
const INPUT_TYPES = [
|
||||||
{ value: "text", label: "텍스트" },
|
{ value: "text", label: "텍스트" },
|
||||||
{ value: "number", label: "숫자" },
|
{ value: "number", label: "숫자" },
|
||||||
{ value: "date", label: "날짜" },
|
{ value: "date", label: "날짜" },
|
||||||
{ value: "textarea", 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({
|
export function DetailFormModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -55,10 +86,35 @@ export function DetailFormModal({
|
||||||
detailTypes,
|
detailTypes,
|
||||||
editData,
|
editData,
|
||||||
mode,
|
mode,
|
||||||
|
selectedItemCode,
|
||||||
}: DetailFormModalProps) {
|
}: DetailFormModalProps) {
|
||||||
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||||
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||||
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -86,6 +142,12 @@ export function DetailFormModal({
|
||||||
}
|
}
|
||||||
}, [open, mode, editData, detailTypes]);
|
}, [open, mode, editData, detailTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && formData.detail_type === "material_input") {
|
||||||
|
loadBomMaterials();
|
||||||
|
}
|
||||||
|
}, [open, formData.detail_type, loadBomMaterials]);
|
||||||
|
|
||||||
const updateField = (field: string, value: any) => {
|
const updateField = (field: string, value: any) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
@ -108,17 +170,33 @@ export function DetailFormModal({
|
||||||
|
|
||||||
const type = formData.detail_type;
|
const type = formData.detail_type;
|
||||||
|
|
||||||
if (type === "check" && !formData.content?.trim()) return;
|
if (type === "checklist" && !formData.content?.trim()) return;
|
||||||
if (type === "inspect" && !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 === "procedure" && !formData.content?.trim()) return;
|
||||||
if (type === "input" && !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 };
|
const submitData = { ...formData };
|
||||||
|
|
||||||
if (type === "info" && !submitData.content?.trim()) {
|
// content 자동 설정 (UI에서 직접 입력이 없는 유형들)
|
||||||
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
|
if (type === "inspection" && submitData.process_inspection_apply === "apply") {
|
||||||
submitData.content = `${targetLabel} 조회`;
|
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);
|
onSubmit(submitData);
|
||||||
|
|
@ -130,7 +208,7 @@ export function DetailFormModal({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
상세 항목 {mode === "add" ? "추가" : "수정"}
|
상세 항목 {mode === "add" ? "추가" : "수정"}
|
||||||
|
|
@ -149,12 +227,11 @@ export function DetailFormModal({
|
||||||
<Select
|
<Select
|
||||||
value={currentType}
|
value={currentType}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
updateField("detail_type", v);
|
|
||||||
setSelectedInspection(null);
|
setSelectedInspection(null);
|
||||||
setFormData((prev) => ({
|
setFormData({
|
||||||
detail_type: v,
|
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">
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
|
@ -170,9 +247,8 @@ export function DetailFormModal({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 체크리스트 */}
|
{/* ============ 체크리스트 ============ */}
|
||||||
{currentType === "check" && (
|
{currentType === "checklist" && (
|
||||||
<>
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
체크 내용 <span className="text-destructive">*</span>
|
체크 내용 <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"
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 검사항목 */}
|
{/* ============ 검사항목 ============ */}
|
||||||
{currentType === "inspect" && (
|
{currentType === "inspection" && (
|
||||||
<>
|
<>
|
||||||
|
{/* 공정검사 적용 여부 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
검사기준 선택 <span className="text-destructive">*</span>
|
공정검사 적용 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-2 flex gap-4">
|
||||||
<Select value="_placeholder" disabled>
|
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||||
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
|
<input
|
||||||
<SelectValue>
|
type="radio"
|
||||||
{selectedInspection
|
name="processInspection"
|
||||||
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
|
checked={formData.process_inspection_apply === "apply"}
|
||||||
: "검사기준을 선택하세요"}
|
onChange={() => updateField("process_inspection_apply", "apply")}
|
||||||
</SelectValue>
|
className="h-4 w-4 accent-primary"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
적용
|
||||||
<SelectItem value="_placeholder">선택</SelectItem>
|
</label>
|
||||||
</SelectContent>
|
<label className="flex cursor-pointer items-center gap-2 text-xs sm:text-sm">
|
||||||
</Select>
|
<input
|
||||||
<Button
|
type="radio"
|
||||||
variant="secondary"
|
name="processInspection"
|
||||||
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
|
checked={formData.process_inspection_apply === "none"}
|
||||||
onClick={() => setInspectionLookupOpen(true)}
|
onChange={() => updateField("process_inspection_apply", "none")}
|
||||||
>
|
className="h-4 w-4 accent-primary"
|
||||||
<Search className="h-3.5 w-3.5" />
|
/>
|
||||||
조회
|
미적용
|
||||||
</Button>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedInspection && (
|
{/* 적용 시: 품목별 검사정보 자동 연동 안내 */}
|
||||||
<div className="rounded border bg-muted/30 p-3">
|
{formData.process_inspection_apply === "apply" && (
|
||||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3">
|
||||||
선택된 검사기준 정보
|
<p className="text-xs font-semibold text-sky-800">
|
||||||
|
품목별 검사정보 (자동 연동)
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
<p>
|
품목에 등록된 검사기준이 자동으로 적용됩니다.
|
||||||
<strong>검사코드:</strong> {selectedInspection.inspection_code}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 미적용 시: 수동 입력 */}
|
||||||
|
{formData.process_inspection_apply === "none" && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
검사 항목명 <span className="text-destructive">*</span>
|
검사 항목명 <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"
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
||||||
|
|
@ -270,39 +332,82 @@ export function DetailFormModal({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">단위</Label>
|
<Label className="text-xs sm:text-sm">단위</Label>
|
||||||
<Input
|
<Select
|
||||||
value={formData.unit || ""}
|
value={formData.unit || ""}
|
||||||
onChange={(e) => updateField("unit", e.target.value)}
|
onValueChange={(v) => updateField("unit", v)}
|
||||||
placeholder="예: mm"
|
>
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* 자동수집 */}
|
||||||
<div>
|
<div className="mt-3 flex items-center gap-3 border-t pt-3">
|
||||||
<Label className="text-xs sm:text-sm">하한값</Label>
|
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs font-medium sm:text-sm">
|
||||||
<Input
|
<Checkbox
|
||||||
value={formData.lower_limit || ""}
|
checked={formData.auto_collect === "Y"}
|
||||||
onChange={(e) => updateField("lower_limit", e.target.value)}
|
onCheckedChange={(checked) => {
|
||||||
placeholder="예: 7.95"
|
updateField("auto_collect", checked ? "Y" : "N");
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
if (!checked) updateField("plc_data", "");
|
||||||
/>
|
}}
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
|
자동수집
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 작업절차 */}
|
{/* ============ 작업절차 ============ */}
|
||||||
{currentType === "procedure" && (
|
{currentType === "procedure" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -322,10 +427,7 @@ export function DetailFormModal({
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.duration_minutes ?? ""}
|
value={formData.duration_minutes ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(
|
updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)
|
||||||
"duration_minutes",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
placeholder="예: 5"
|
placeholder="예: 5"
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
|
@ -334,7 +436,7 @@ export function DetailFormModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 직접입력 */}
|
{/* ============ 직접입력 ============ */}
|
||||||
{currentType === "input" && (
|
{currentType === "input" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -359,9 +461,7 @@ export function DetailFormModal({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{INPUT_TYPES.map((t) => (
|
{INPUT_TYPES.map((t) => (
|
||||||
<SelectItem key={t.value} value={t.value}>
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
조회 대상 <span className="text-destructive">*</span>
|
설비점검 적용 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</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
|
<Select
|
||||||
value={formData.lookup_target || ""}
|
value={formData.condition_unit || ""}
|
||||||
onValueChange={(v) => updateField("lookup_target", v)}
|
onValueChange={(v) => updateField("condition_unit", v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue placeholder="선택하세요" />
|
<SelectValue placeholder="단위" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{LOOKUP_TARGETS.map((t) => (
|
{UNIT_OPTIONS.map((u) => (
|
||||||
<SelectItem key={t.value} value={t.value}>
|
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
|
||||||
|
{/* 기준값 ± 오차범위 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={formData.display_fields || ""}
|
type="number"
|
||||||
onChange={(e) => updateField("display_fields", e.target.value)}
|
step="any"
|
||||||
placeholder="예: 설비명, 설비코드"
|
value={formData.condition_base_value || ""}
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
onChange={(e) => updateField("condition_base_value", e.target.value)}
|
||||||
|
placeholder="기준값"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{currentType && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface WorkItemDetailListProps {
|
||||||
details: WorkItemDetail[];
|
details: WorkItemDetail[];
|
||||||
detailTypes: DetailTypeDefinition[];
|
detailTypes: DetailTypeDefinition[];
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
selectedItemCode?: string;
|
||||||
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
|
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
|
||||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||||
onDeleteDetail: (id: string) => void;
|
onDeleteDetail: (id: string) => void;
|
||||||
|
|
@ -23,6 +24,7 @@ export function WorkItemDetailList({
|
||||||
details,
|
details,
|
||||||
detailTypes,
|
detailTypes,
|
||||||
readonly,
|
readonly,
|
||||||
|
selectedItemCode,
|
||||||
onCreateDetail,
|
onCreateDetail,
|
||||||
onUpdateDetail,
|
onUpdateDetail,
|
||||||
onDeleteDetail,
|
onDeleteDetail,
|
||||||
|
|
@ -66,11 +68,12 @@ export function WorkItemDetailList({
|
||||||
|
|
||||||
const getContentSummary = (detail: WorkItemDetail): string => {
|
const getContentSummary = (detail: WorkItemDetail): string => {
|
||||||
const type = detail.detail_type;
|
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];
|
const parts = [detail.content];
|
||||||
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
||||||
if (detail.lower_limit || detail.upper_limit) {
|
if (detail.base_value) {
|
||||||
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
|
parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`);
|
||||||
}
|
}
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
@ -83,20 +86,24 @@ export function WorkItemDetailList({
|
||||||
number: "숫자",
|
number: "숫자",
|
||||||
date: "날짜",
|
date: "날짜",
|
||||||
textarea: "장문",
|
textarea: "장문",
|
||||||
select: "선택형",
|
|
||||||
};
|
};
|
||||||
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
||||||
}
|
}
|
||||||
if (type === "info" && detail.lookup_target) {
|
if (type === "lookup") return "품목 등록 문서 (자동 연동)";
|
||||||
const targetMap: Record<string, string> = {
|
if (type === "equip_inspection") {
|
||||||
equipment: "설비정보",
|
return detail.equip_inspection_apply === "apply"
|
||||||
material: "자재정보",
|
? "설비 점검항목 (설비정보 연동)"
|
||||||
worker: "작업자정보",
|
: detail.content || "설비점검";
|
||||||
tool: "공구정보",
|
|
||||||
document: "문서정보",
|
|
||||||
};
|
|
||||||
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
|
|
||||||
}
|
}
|
||||||
|
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 || "-";
|
return detail.content || "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -214,6 +221,7 @@ export function WorkItemDetailList({
|
||||||
detailTypes={detailTypes}
|
detailTypes={detailTypes}
|
||||||
editData={editTarget}
|
editData={editTarget}
|
||||||
mode={modalMode}
|
mode={modalMode}
|
||||||
|
selectedItemCode={selectedItemCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface WorkPhaseSectionProps {
|
||||||
selectedWorkItemDetails: WorkItemDetail[];
|
selectedWorkItemDetails: WorkItemDetail[];
|
||||||
detailTypes: DetailTypeDefinition[];
|
detailTypes: DetailTypeDefinition[];
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
selectedItemCode?: string;
|
||||||
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
|
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
|
||||||
onAddWorkItem: (phase: string) => void;
|
onAddWorkItem: (phase: string) => void;
|
||||||
onEditWorkItem: (item: WorkItem) => void;
|
onEditWorkItem: (item: WorkItem) => void;
|
||||||
|
|
@ -36,6 +37,7 @@ export function WorkPhaseSection({
|
||||||
selectedWorkItemDetails,
|
selectedWorkItemDetails,
|
||||||
detailTypes,
|
detailTypes,
|
||||||
readonly,
|
readonly,
|
||||||
|
selectedItemCode,
|
||||||
onSelectWorkItem,
|
onSelectWorkItem,
|
||||||
onAddWorkItem,
|
onAddWorkItem,
|
||||||
onEditWorkItem,
|
onEditWorkItem,
|
||||||
|
|
@ -107,6 +109,7 @@ export function WorkPhaseSection({
|
||||||
details={selectedWorkItemDetails}
|
details={selectedWorkItemDetails}
|
||||||
detailTypes={detailTypes}
|
detailTypes={detailTypes}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
|
selectedItemCode={selectedItemCode}
|
||||||
onCreateDetail={(data) =>
|
onCreateDetail={(data) =>
|
||||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
|
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,15 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
||||||
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||||
],
|
],
|
||||||
detailTypes: [
|
detailTypes: [
|
||||||
{ value: "check", label: "체크리스트" },
|
{ value: "checklist", label: "체크리스트" },
|
||||||
{ value: "inspect", label: "검사항목" },
|
{ value: "inspection", label: "검사항목" },
|
||||||
{ value: "procedure", label: "작업절차" },
|
{ value: "procedure", label: "작업절차" },
|
||||||
{ value: "input", 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,
|
splitRatio: 30,
|
||||||
leftPanelTitle: "품목 및 공정 선택",
|
leftPanelTitle: "품목 및 공정 선택",
|
||||||
|
|
|
||||||
|
|
@ -91,19 +91,53 @@ export interface WorkItemDetail {
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
created_date?: string;
|
created_date?: string;
|
||||||
// 검사항목 전용
|
|
||||||
|
// 검사항목(inspection) 전용
|
||||||
|
process_inspection_apply?: string; // "apply" | "none"
|
||||||
inspection_code?: string;
|
inspection_code?: string;
|
||||||
inspection_method?: string;
|
inspection_method?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
base_value?: string;
|
||||||
|
tolerance?: string;
|
||||||
lower_limit?: string;
|
lower_limit?: string;
|
||||||
upper_limit?: string;
|
upper_limit?: string;
|
||||||
// 작업절차 전용
|
auto_collect?: string; // "Y" | "N"
|
||||||
|
plc_data?: string;
|
||||||
|
|
||||||
|
// 작업절차(procedure) 전용
|
||||||
duration_minutes?: number;
|
duration_minutes?: number;
|
||||||
// 직접입력 전용
|
|
||||||
|
// 직접입력(input) 전용
|
||||||
input_type?: string;
|
input_type?: string;
|
||||||
// 정보조회 전용
|
|
||||||
|
// 문서참조(lookup) 전용
|
||||||
lookup_target?: string;
|
lookup_target?: string;
|
||||||
display_fields?: 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 {
|
export interface InspectionStandard {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue