feat: add BOM materials retrieval functionality
- Implemented a new API endpoint for retrieving BOM materials based on item codes, enhancing the ability to manage and view component materials. - Added necessary SQL queries to fetch BOM details, ensuring that the data is filtered by company code for multi-tenancy support. - Updated frontend components to integrate BOM materials fetching, allowing for better visibility and management of materials in the process workflow. These changes aim to streamline the management of BOM materials, facilitating better tracking and organization within the application.
This commit is contained in:
parent
ffcede7e66
commit
199fa60ef5
|
|
@ -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,139 +247,167 @@ 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>
|
</Label>
|
||||||
</Label>
|
<Input
|
||||||
<Input
|
value={formData.content || ""}
|
||||||
value={formData.content || ""}
|
onChange={(e) => updateField("content", e.target.value)}
|
||||||
onChange={(e) => updateField("content", e.target.value)}
|
placeholder="예: 전원 상태 확인"
|
||||||
placeholder="예: 전원 상태 확인"
|
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 className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
품목에 등록된 검사기준이 자동으로 적용됩니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
|
||||||
<p>
|
|
||||||
<strong>검사코드:</strong> {selectedInspection.inspection_code}
|
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{/* 미적용 시: 수동 입력 */}
|
||||||
<Label className="text-xs sm:text-sm">
|
{formData.process_inspection_apply === "none" && (
|
||||||
검사 항목명 <span className="text-destructive">*</span>
|
<>
|
||||||
</Label>
|
<div>
|
||||||
<Input
|
<Label className="text-xs sm:text-sm">
|
||||||
value={formData.content || ""}
|
검사 항목명 <span className="text-destructive">*</span>
|
||||||
onChange={(e) => updateField("content", e.target.value)}
|
</Label>
|
||||||
placeholder="예: 외경 치수"
|
<Input
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
value={formData.content || ""}
|
||||||
/>
|
onChange={(e) => updateField("content", e.target.value)}
|
||||||
</div>
|
placeholder="예: 외경 치수"
|
||||||
|
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>
|
||||||
|
<Input
|
||||||
|
value={formData.inspection_method || ""}
|
||||||
|
onChange={(e) => updateField("inspection_method", e.target.value)}
|
||||||
|
placeholder="예: 마이크로미터"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">단위</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.unit || ""}
|
||||||
|
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="grid grid-cols-2 gap-3">
|
{/* 기준값 ± 오차범위 */}
|
||||||
<div>
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<div className="flex-1">
|
||||||
value={formData.inspection_method || ""}
|
<Input
|
||||||
onChange={(e) => updateField("inspection_method", e.target.value)}
|
type="number"
|
||||||
placeholder="예: 마이크로미터"
|
step="any"
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
value={formData.base_value || ""}
|
||||||
/>
|
onChange={(e) => updateField("base_value", e.target.value)}
|
||||||
</div>
|
placeholder="기준값"
|
||||||
<div>
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
<Label className="text-xs sm:text-sm">단위</Label>
|
/>
|
||||||
<Input
|
</div>
|
||||||
value={formData.unit || ""}
|
<span className="text-sm font-semibold text-muted-foreground">±</span>
|
||||||
onChange={(e) => updateField("unit", e.target.value)}
|
<div className="flex-1">
|
||||||
placeholder="예: mm"
|
<Input
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
type="number"
|
||||||
/>
|
step="any"
|
||||||
</div>
|
value={formData.tolerance || ""}
|
||||||
</div>
|
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>
|
<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>
|
</label>
|
||||||
<Input
|
<Select
|
||||||
value={formData.upper_limit || ""}
|
value={formData.plc_data || ""}
|
||||||
onChange={(e) => updateField("upper_limit", e.target.value)}
|
onValueChange={(v) => updateField("plc_data", v)}
|
||||||
placeholder="예: 8.05"
|
disabled={formData.auto_collect !== "Y"}
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
>
|
||||||
/>
|
<SelectTrigger className={`h-8 flex-1 text-xs sm:h-10 sm:text-sm ${formData.auto_collect !== "Y" ? "opacity-50" : ""}`}>
|
||||||
</div>
|
<SelectValue placeholder="수집데이터 선택" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLC_DATA_OPTIONS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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>
|
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2.5">
|
||||||
<Label className="text-xs sm:text-sm">
|
<span className="text-sm">📄</span>
|
||||||
조회 대상 <span className="text-destructive">*</span>
|
<span className="text-xs text-blue-800">
|
||||||
</Label>
|
해당 품목에 등록된 문서를 자동으로 불러옵니다.
|
||||||
<Select
|
</span>
|
||||||
value={formData.lookup_target || ""}
|
|
||||||
onValueChange={(v) => updateField("lookup_target", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 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>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
<Label className="text-xs sm:text-sm">참조 문서 목록</Label>
|
||||||
<Input
|
<div className="mt-1 rounded-lg border p-3">
|
||||||
value={formData.display_fields || ""}
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
onChange={(e) => updateField("display_fields", e.target.value)}
|
품목이 선택되지 않았습니다.
|
||||||
placeholder="예: 설비명, 설비코드"
|
</p>
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
</div>
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
</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.condition_unit || ""}
|
||||||
|
onValueChange={(v) => updateField("condition_unit", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="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="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
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 && (
|
{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