feat: Implement default version management for routing versions
- Added functionality to set and unset default versions for routing items. - Introduced new API endpoints for setting and unsetting default versions. - Enhanced the ItemRoutingComponent to support toggling default versions with user feedback. - Updated database queries to handle default version logic effectively. - Improved the overall user experience by allowing easy management of routing versions.
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||
|
||||
interface DetailFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Partial<WorkItemDetail>) => void;
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
editData?: WorkItemDetail | null;
|
||||
mode: "add" | "edit";
|
||||
}
|
||||
|
||||
const LOOKUP_TARGETS = [
|
||||
{ value: "equipment", label: "설비정보" },
|
||||
{ value: "material", label: "자재정보" },
|
||||
{ value: "worker", label: "작업자정보" },
|
||||
{ value: "tool", label: "공구정보" },
|
||||
{ value: "document", label: "문서정보" },
|
||||
];
|
||||
|
||||
const INPUT_TYPES = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "textarea", label: "장문텍스트" },
|
||||
{ value: "select", label: "선택형" },
|
||||
];
|
||||
|
||||
export function DetailFormModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
detailTypes,
|
||||
editData,
|
||||
mode,
|
||||
}: DetailFormModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && editData) {
|
||||
setFormData({ ...editData });
|
||||
if (editData.inspection_code) {
|
||||
setSelectedInspection({
|
||||
id: "",
|
||||
inspection_code: editData.inspection_code,
|
||||
inspection_item: editData.content || "",
|
||||
inspection_method: editData.inspection_method || "",
|
||||
unit: editData.unit || "",
|
||||
lower_limit: editData.lower_limit || "",
|
||||
upper_limit: editData.upper_limit || "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "Y",
|
||||
});
|
||||
setSelectedInspection(null);
|
||||
}
|
||||
}
|
||||
}, [open, mode, editData, detailTypes]);
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleInspectionSelect = (item: InspectionStandard) => {
|
||||
setSelectedInspection(item);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
inspection_code: item.inspection_code,
|
||||
content: item.inspection_item,
|
||||
inspection_method: item.inspection_method,
|
||||
unit: item.unit,
|
||||
lower_limit: item.lower_limit || "",
|
||||
upper_limit: item.upper_limit || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.detail_type) return;
|
||||
|
||||
const type = formData.detail_type;
|
||||
|
||||
if (type === "check" && !formData.content?.trim()) return;
|
||||
if (type === "inspect" && !formData.content?.trim()) return;
|
||||
if (type === "procedure" && !formData.content?.trim()) return;
|
||||
if (type === "input" && !formData.content?.trim()) return;
|
||||
if (type === "info" && !formData.lookup_target) return;
|
||||
|
||||
onSubmit(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const currentType = formData.detail_type || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
상세 항목 {mode === "add" ? "추가" : "수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
상세 항목의 유형을 선택하고 내용을 입력하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 유형 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
유형 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentType}
|
||||
onValueChange={(v) => {
|
||||
updateField("detail_type", v);
|
||||
setSelectedInspection(null);
|
||||
setFormData((prev) => ({
|
||||
detail_type: v,
|
||||
is_required: prev.is_required || "Y",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 체크리스트 */}
|
||||
{currentType === "check" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
체크 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 전원 상태 확인"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 검사항목 */}
|
||||
{currentType === "inspect" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사기준 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Select value="_placeholder" disabled>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue>
|
||||
{selectedInspection
|
||||
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
|
||||
: "검사기준을 선택하세요"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_placeholder">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => setInspectionLookupOpen(true)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInspection && (
|
||||
<div className="rounded border bg-muted/30 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
선택된 검사기준 정보
|
||||
</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>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
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>
|
||||
<Input
|
||||
value={formData.unit || ""}
|
||||
onChange={(e) => updateField("unit", e.target.value)}
|
||||
placeholder="예: mm"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">하한값</Label>
|
||||
<Input
|
||||
value={formData.lower_limit || ""}
|
||||
onChange={(e) => updateField("lower_limit", e.target.value)}
|
||||
placeholder="예: 7.95"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">상한값</Label>
|
||||
<Input
|
||||
value={formData.upper_limit || ""}
|
||||
onChange={(e) => updateField("upper_limit", e.target.value)}
|
||||
placeholder="예: 8.05"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 작업절차 */}
|
||||
{currentType === "procedure" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
작업 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", 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>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.duration_minutes ?? ""}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"duration_minutes",
|
||||
e.target.value ? Number(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
placeholder="예: 5"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 직접입력 */}
|
||||
{currentType === "input" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
입력 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", 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.input_type || "text"}
|
||||
onValueChange={(v) => updateField("input_type", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 정보조회 */}
|
||||
{currentType === "info" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
조회 대상 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
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>
|
||||
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
||||
<Input
|
||||
value={formData.display_fields || ""}
|
||||
onChange={(e) => updateField("display_fields", e.target.value)}
|
||||
placeholder="예: 설비명, 설비코드"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필수 여부 (모든 유형 공통) */}
|
||||
{currentType && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필수 여부</Label>
|
||||
<Select
|
||||
value={formData.is_required || "Y"}
|
||||
onValueChange={(v) => updateField("is_required", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">필수</SelectItem>
|
||||
<SelectItem value="N">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{mode === "add" ? "추가" : "수정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InspectionStandardLookup
|
||||
open={inspectionLookupOpen}
|
||||
onClose={() => setInspectionLookupOpen(false)}
|
||||
onSelect={handleInspectionSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { InspectionStandard } from "../types";
|
||||
|
||||
interface InspectionStandardLookupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (item: InspectionStandard) => void;
|
||||
}
|
||||
|
||||
export function InspectionStandardLookup({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: InspectionStandardLookupProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const search: Record<string, any> = {};
|
||||
if (searchText.trim()) {
|
||||
search.inspection_item = searchText.trim();
|
||||
search.inspection_code = searchText.trim();
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
page: "1",
|
||||
size: "100",
|
||||
enableEntityJoin: "true",
|
||||
...(searchText.trim() ? { search: JSON.stringify(search) } : {}),
|
||||
});
|
||||
const res = await apiClient.get(
|
||||
`/table-management/tables/inspection_standard/data-with-joins?${params}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const result = res.data.data;
|
||||
setData(Array.isArray(result) ? result : result?.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("검사기준 조회 실패", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchData();
|
||||
}
|
||||
}, [open, fetchData]);
|
||||
|
||||
const handleSelect = (item: any) => {
|
||||
onSelect({
|
||||
id: item.id,
|
||||
inspection_code: item.inspection_code || "",
|
||||
inspection_item: item.inspection_item || item.inspection_criteria || "",
|
||||
inspection_method: item.inspection_method || "",
|
||||
unit: item.unit || "",
|
||||
lower_limit: item.lower_limit || "",
|
||||
upper_limit: item.upper_limit || "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Search className="h-5 w-5" />
|
||||
검사기준 조회
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
검사기준을 검색하여 선택하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="검사항목명 또는 검사코드로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchData()}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-auto rounded border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사코드
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사항목
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사방법
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
하한
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
상한
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
단위
|
||||
</th>
|
||||
<th className="w-16 px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
선택
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
조회 중...
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
검사기준이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item, idx) => (
|
||||
<tr
|
||||
key={item.id || idx}
|
||||
className="border-b transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-3 py-2">{item.inspection_code || "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.inspection_item || item.inspection_criteria || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.inspection_method || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{item.lower_limit || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{item.upper_limit || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function ItemProcessSelector({
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.item_code} className="mb-1">
|
||||
<div key={item.id} className="mb-1">
|
||||
{/* 품목 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleItem(item.item_code, item.item_name)}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
||||
import { DetailFormModal } from "./DetailFormModal";
|
||||
|
||||
interface WorkItemDetailListProps {
|
||||
workItem: WorkItem | null;
|
||||
@@ -34,20 +27,13 @@ export function WorkItemDetailList({
|
||||
onUpdateDetail,
|
||||
onDeleteDetail,
|
||||
}: WorkItemDetailListProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||
const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
|
||||
|
||||
if (!workItem) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
왼쪽에서 항목을 선택하세요
|
||||
</p>
|
||||
@@ -58,25 +44,60 @@ export function WorkItemDetailList({
|
||||
const getTypeLabel = (value?: string) =>
|
||||
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newData.content?.trim()) return;
|
||||
onCreateDetail({
|
||||
...newData,
|
||||
sort_order: details.length + 1,
|
||||
});
|
||||
setNewData({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
setIsAdding(false);
|
||||
const handleOpenAdd = () => {
|
||||
setModalMode("add");
|
||||
setEditTarget(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateDetail(id, editData);
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
const handleOpenEdit = (detail: WorkItemDetail) => {
|
||||
setModalMode("edit");
|
||||
setEditTarget(detail);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (data: Partial<WorkItemDetail>) => {
|
||||
if (modalMode === "add") {
|
||||
onCreateDetail({ ...data, sort_order: details.length + 1 });
|
||||
} else if (editTarget) {
|
||||
onUpdateDetail(editTarget.id, data);
|
||||
}
|
||||
};
|
||||
|
||||
const getContentSummary = (detail: WorkItemDetail): string => {
|
||||
const type = detail.detail_type;
|
||||
if (type === "inspect" && detail.inspection_code) {
|
||||
const parts = [detail.content];
|
||||
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
||||
if (detail.lower_limit || detail.upper_limit) {
|
||||
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (type === "procedure" && detail.duration_minutes) {
|
||||
return `${detail.content} (${detail.duration_minutes}분)`;
|
||||
}
|
||||
if (type === "input" && detail.input_type) {
|
||||
const typeMap: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
number: "숫자",
|
||||
date: "날짜",
|
||||
textarea: "장문",
|
||||
select: "선택형",
|
||||
};
|
||||
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
||||
}
|
||||
if (type === "info" && detail.lookup_target) {
|
||||
const targetMap: Record<string, string> = {
|
||||
equipment: "설비정보",
|
||||
material: "자재정보",
|
||||
worker: "작업자정보",
|
||||
tool: "공구정보",
|
||||
document: "문서정보",
|
||||
};
|
||||
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
|
||||
}
|
||||
return detail.content || "-";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -94,7 +115,7 @@ export function WorkItemDetailList({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => setIsAdding(true)}
|
||||
onClick={handleOpenAdd}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
상세 추가
|
||||
@@ -132,242 +153,51 @@ export function WorkItemDetailList({
|
||||
key={detail.id}
|
||||
className="border-b transition-colors hover:bg-muted/30"
|
||||
>
|
||||
{editingId === detail.id ? (
|
||||
<>
|
||||
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Select
|
||||
value={editData.detail_type || detail.detail_type || ""}
|
||||
onValueChange={(v) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
detail_type: v,
|
||||
}))
|
||||
}
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{getContentSummary(detail)}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={detail.is_required === "Y" ? "default" : "secondary"}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleOpenEdit(detail)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem
|
||||
key={t.value}
|
||||
value={t.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Input
|
||||
value={editData.content ?? detail.content}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
content: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Select
|
||||
value={editData.is_required ?? detail.is_required}
|
||||
onValueChange={(v) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
is_required: v,
|
||||
}))
|
||||
}
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs">
|
||||
필수
|
||||
</SelectItem>
|
||||
<SelectItem value="N" className="text-xs">
|
||||
선택
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-600"
|
||||
onClick={() => handleSaveEdit(detail.id)}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{detail.content}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
detail.is_required === "Y" ? "default" : "secondary"
|
||||
}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setEditingId(detail.id);
|
||||
setEditData({
|
||||
detail_type: detail.detail_type,
|
||||
content: detail.content,
|
||||
is_required: detail.is_required,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* 추가 행 */}
|
||||
{isAdding && (
|
||||
<tr className="border-b bg-primary/5">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{details.length + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Select
|
||||
value={newData.detail_type || ""}
|
||||
onValueChange={(v) =>
|
||||
setNewData((prev) => ({ ...prev, detail_type: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem
|
||||
key={t.value}
|
||||
value={t.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="상세 내용 입력"
|
||||
value={newData.content || ""}
|
||||
onChange={(e) =>
|
||||
setNewData((prev) => ({
|
||||
...prev,
|
||||
content: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Select
|
||||
value={newData.is_required || "N"}
|
||||
onValueChange={(v) =>
|
||||
setNewData((prev) => ({ ...prev, is_required: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs">
|
||||
필수
|
||||
</SelectItem>
|
||||
<SelectItem value="N" className="text-xs">
|
||||
선택
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-600"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsAdding(false)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{details.length === 0 && !isAdding && (
|
||||
{details.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||
@@ -375,6 +205,16 @@ export function WorkItemDetailList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<DetailFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
detailTypes={detailTypes}
|
||||
editData={editTarget}
|
||||
mode={modalMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
||||
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||
],
|
||||
detailTypes: [
|
||||
{ value: "CHECK", label: "체크" },
|
||||
{ value: "INSPECTION", label: "검사" },
|
||||
{ value: "MEASUREMENT", label: "측정" },
|
||||
{ value: "check", label: "체크리스트" },
|
||||
{ value: "inspect", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "info", label: "정보조회" },
|
||||
],
|
||||
splitRatio: 30,
|
||||
leftPanelTitle: "품목 및 공정 선택",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectionState,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api/process-work-standard";
|
||||
const API_BASE = "/process-work-standard";
|
||||
|
||||
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
|
||||
@@ -87,6 +87,29 @@ export interface WorkItemDetail {
|
||||
sort_order: number;
|
||||
remark?: string;
|
||||
created_date?: string;
|
||||
// 검사항목 전용
|
||||
inspection_code?: string;
|
||||
inspection_method?: string;
|
||||
unit?: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
// 작업절차 전용
|
||||
duration_minutes?: number;
|
||||
// 직접입력 전용
|
||||
input_type?: string;
|
||||
// 정보조회 전용
|
||||
lookup_target?: string;
|
||||
display_fields?: string;
|
||||
}
|
||||
|
||||
export interface InspectionStandard {
|
||||
id: string;
|
||||
inspection_code: string;
|
||||
inspection_item: string;
|
||||
inspection_method: string;
|
||||
unit: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user