refactor: Update logistics and inspection pages for improved field labels and validation

- Changed field labels for clarity in logistics info page, updating "유형" to "업체유형" and enhancing placeholders for better user guidance.
- Added required validation for fields in both logistics and inspection forms, ensuring essential data is captured before submission.
- Introduced dynamic generation of route names based on departure and destination inputs in the logistics info page.
- Enhanced inspection management page with additional user options and improved handling of inspection types and criteria.

These changes aim to enhance user experience and data integrity across logistics and inspection functionalities.
This commit is contained in:
kjs
2026-04-03 16:02:14 +09:00
parent 64e26f2c3f
commit eee20c6581
7 changed files with 1513 additions and 253 deletions
@@ -201,13 +201,32 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
);
}
// 재고 이력 기록 (inventory_history)
const afterStockRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_number, warehouse_code, location_code,
history_type, history_date, change_qty, after_qty, reason,
created_by, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW()::date, $5, $6, $7, $8, NOW())`,
[companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId]
);
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
@@ -253,6 +253,25 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
// 2b-2. 재고 이력 기록 (inventory_history)
const afterStockRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_number, warehouse_code, location_code,
history_type, history_date, change_qty, after_qty, reason,
created_by, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW()::date, $5, $6, $7, $8, NOW())`,
[companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId]
);
}
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
@@ -516,6 +535,25 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
AND COALESCE(location_code, '') = COALESCE($5, '')`,
[inQty, companyCode, itemCode, whCode || '', locCode || '']
);
// 입고취소 이력 기록
const afterStockRes = await client.query(
`SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_number, warehouse_code, location_code,
history_type, history_date, change_qty, after_qty, reason,
created_by, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW()::date, $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
);
}
// 구매입고 발주 롤백: purchase_order_mng 기반
@@ -96,7 +96,7 @@ const TAB_CONFIGS: TabConfig[] = [
columns: [
{ key: "carrier_code", label: "업체코드", width: "120px" },
{ key: "carrier_name", label: "업체명", width: "160px" },
{ key: "carrier_type", label: "유형", width: "100px" },
{ key: "carrier_type", label: "업체유형", width: "100px" },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "contact_phone", label: "연락처", width: "130px" },
{ key: "email", label: "이메일", width: "180px" },
@@ -107,12 +107,12 @@ const TAB_CONFIGS: TabConfig[] = [
formFields: [
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
{ key: "carrier_type", label: "유형", type: "select", categoryKey: "carrier_mng:carrier_type", placeholder: "유형을 선택해주세요" },
{ key: "carrier_type", label: "업체유형", type: "select", required: true, categoryKey: "carrier_mng:carrier_type", placeholder: "업체유형을 선택해주세요" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "010-0000-0000" },
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "예: 02-1234-5678" },
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
{ key: "rating", label: "등급", type: "select", options: [1, 2, 3, 4, 5].map((v) => ({ value: String(v), label: `${v}등급` })) },
{ key: "rating", label: "등급", type: "select", categoryKey: "carrier_mng:rating", placeholder: "등급을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
],
},
@@ -155,6 +155,7 @@ const TAB_CONFIGS: TabConfig[] = [
{ key: "contract_start_date", label: "시작일", width: "110px" },
{ key: "contract_end_date", label: "종료일", width: "110px" },
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
@@ -163,6 +164,7 @@ const TAB_CONFIGS: TabConfig[] = [
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명을 입력해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
],
},
@@ -184,9 +186,8 @@ const TAB_CONFIGS: TabConfig[] = [
],
formFields: [
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
{ key: "route_name", label: "구간명", type: "text", required: true, placeholder: "구간명을 입력해주세요" },
{ key: "departure", label: "출발지", type: "text", placeholder: "출발지" },
{ key: "destination", label: "도착지", type: "text", placeholder: "도착지" },
{ key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" },
{ key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" },
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
@@ -202,19 +203,21 @@ const TAB_CONFIGS: TabConfig[] = [
columns: [
{ key: "vehicle_code", label: "차량코드", width: "120px" },
{ key: "vehicle_number", label: "차량번호", width: "120px" },
{ key: "vehicle_type", label: "차", width: "100px" },
{ key: "vehicle_type", label: "차량유형", width: "100px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
{ key: "driver_name", label: "운전자", width: "100px" },
{ key: "last_maintenance_date", label: "최종정비일", width: "110px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
{ key: "vehicle_type", label: "차", type: "select", categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차을 선택해주세요" },
{ key: "vehicle_type", label: "차량유형", type: "select", required: true, categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차량유형을 선택해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
{ key: "last_maintenance_date", label: "최종정비일", type: "date" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
],
},
@@ -433,16 +436,22 @@ export default function LogisticsInfoPage() {
}
try {
// 배송구간: 출발지→도착지 로 구간명 자동 생성
const saveData = { ...formData };
if (activeTab === "route" && saveData.departure && saveData.destination) {
saveData.route_name = `${saveData.departure}${saveData.destination}`;
}
if (editMode && editId) {
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
originalData: { id: editId },
updatedData: formData,
updatedData: saveData,
});
toast.success("수정이 완료되었어요.");
} else {
await apiClient.post(
`/table-management/tables/${config.tableName}/add`,
{ id: crypto.randomUUID(), ...formData }
{ id: crypto.randomUUID(), ...saveData }
);
toast.success("등록이 완료되었어요.");
}
@@ -18,7 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@@ -43,6 +43,7 @@ import {
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
@@ -50,6 +51,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import {
getOutboundList,
createOutbound,
updateOutbound,
deleteOutbound,
generateOutboundNumber,
getOutboundWarehouses,
@@ -146,6 +148,10 @@ export default function OutboundPage() {
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
const [saving, setSaving] = useState(false);
// 수정 모드 (등록 모달을 재활용)
const [editMode, setEditMode] = useState(false);
const [editItemIds, setEditItemIds] = useState<string[]>([]);
// 소스 데이터
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceLoading, setSourceLoading] = useState(false);
@@ -249,6 +255,8 @@ export default function OutboundPage() {
const openRegisterModal = async () => {
const defaultType = "판매출고";
setEditMode(false);
setEditItemIds([]);
setModalOutboundType(defaultType);
setModalOutboundDate(new Date().toISOString().split("T")[0]);
setModalWarehouse("");
@@ -273,6 +281,43 @@ export default function OutboundPage() {
}
};
// 수정 모달 열기 (같은 출고번호 묶어서)
const openEditModal = (row: OutboundItem) => {
const outNo = row.outbound_number;
const grouped = data.filter((d) => d.outbound_number === outNo);
const first = grouped[0] || row;
setEditMode(true);
setEditItemIds(grouped.map((g) => g.id));
setModalOutboundNo(outNo);
setModalOutboundType(first.outbound_type || "판매출고");
setModalOutboundDate(first.outbound_date ? first.outbound_date.slice(0, 10) : "");
setModalWarehouse(first.warehouse_code || "");
setModalLocation(first.location_code || "");
setModalManager(first.manager_id || "");
setModalMemo(first.memo || "");
setSelectedItems(
grouped.map((g) => ({
key: g.id,
outbound_type: g.outbound_type || "",
reference_number: g.reference_number || "",
customer_code: g.customer_code || "",
customer_name: g.customer_name || "",
item_number: g.item_code || "",
item_name: g.item_name || "",
spec: g.specification || "",
material: g.material || "",
unit: g.unit || "",
outbound_qty: Number(g.outbound_qty) || 0,
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_type: g.source_type || "",
source_id: g.source_id || "",
}))
);
setIsModalOpen(true);
};
const searchSourceData = useCallback(async () => {
setSourcePage(1);
await loadSourceData(modalOutboundType, sourceKeyword || undefined);
@@ -445,44 +490,67 @@ export default function OutboundPage() {
setSaving(true);
try {
const res = await createOutbound({
outbound_number: modalOutboundNo,
outbound_date: modalOutboundDate,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
manager_id: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
outbound_type: item.outbound_type,
reference_number: item.reference_number,
customer_code: item.customer_code,
customer_name: item.customer_name,
item_code: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
outbound_qty: item.outbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_type: item.source_type,
source_id: item.source_id,
outbound_status: "출고완료",
})),
});
if (res.success) {
alert(res.message || "출고 등록 완료");
if (editMode) {
// 수정 모드: 각 아이템별 update
await Promise.all(
selectedItems.map((item) =>
updateOutbound(item.key, {
outbound_date: modalOutboundDate,
outbound_qty: item.outbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
manager_id: modalManager || undefined,
memo: modalMemo || undefined,
} as any)
)
);
toast.success("출고 정보를 수정했어요");
setIsModalOpen(false);
fetchList();
} else {
const res = await createOutbound({
outbound_number: modalOutboundNo,
outbound_date: modalOutboundDate,
warehouse_code: modalWarehouse || undefined,
location_code: modalLocation || undefined,
manager_id: modalManager || undefined,
memo: modalMemo || undefined,
items: selectedItems.map((item) => ({
outbound_type: item.outbound_type,
reference_number: item.reference_number,
customer_code: item.customer_code,
customer_name: item.customer_name,
item_code: item.item_number,
item_name: item.item_name,
spec: item.spec,
material: item.material,
unit: item.unit,
outbound_qty: item.outbound_qty,
unit_price: item.unit_price,
total_amount: item.total_amount,
source_type: item.source_type,
source_id: item.source_id,
outbound_status: "출고완료",
})),
});
if (res.success) {
toast.success(res.message || "출고 등록 완료");
setIsModalOpen(false);
fetchList();
}
}
} catch {
alert("출고 등록 중 오류가 발생했습니다.");
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
};
// 합계 계산
const totalSummary = useMemo(() => {
return {
@@ -593,6 +661,7 @@ export default function OutboundPage() {
checkedIds.includes(row.id) && "bg-primary/5"
)}
onClick={() => toggleCheck(row.id)}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center"
@@ -673,12 +742,12 @@ export default function OutboundPage() {
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex flex-col gap-0 p-0 sm:max-w-[1600px] w-[95vw] h-[90vh] overflow-hidden">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle> </DialogTitle>
<DialogDescription> , .</DialogDescription>
<DialogTitle>{editMode ? "출고 수정" : "출고 등록"}</DialogTitle>
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
</DialogHeader>
{/* 출고유형 선택 */}
<div className="flex shrink-0 items-center gap-4 border-b bg-muted/30 px-6 py-3">
{/* 출고유형 선택 (수정 모드에서는 숨김) */}
{!editMode && <div className="flex shrink-0 items-center gap-4 border-b bg-muted/30 px-6 py-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
<SelectTrigger className="h-9 w-[160px] text-sm">
@@ -699,13 +768,13 @@ export default function OutboundPage() {
? "발주(입고) 데이터에서 반품 출고 처리해요"
: "품목 데이터를 직접 선택하여 출고 처리해요"}
</span>
</div>
</div>}
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 소스 데이터 */}
<ResizablePanel defaultSize={60} minSize={35}>
{/* 좌측: 소스 데이터 (수정 모드에서는 숨김) */}
{!editMode && <ResizablePanel defaultSize={60} minSize={35}>
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Input
@@ -809,12 +878,12 @@ export default function OutboundPage() {
</div>
)}
</div>
</ResizablePanel>
</ResizablePanel>}
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
{!editMode && <ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />}
{/* 우측: 출고 정보 + 선택 품목 */}
<ResizablePanel defaultSize={40} minSize={25}>
<ResizablePanel defaultSize={editMode ? 100 : 40} minSize={25}>
<div className="flex h-full flex-col">
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
<h4 className="text-[13px] font-bold text-foreground"> </h4>
@@ -906,7 +975,7 @@ export default function OutboundPage() {
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[30px] p-2" />
{!editMode && <TableHead className="w-[30px] p-2" />}
</TableRow>
</TableHeader>
<TableBody>
@@ -946,7 +1015,7 @@ export default function OutboundPage() {
<TableCell className="p-2 text-right text-[13px] font-semibold">
{item.total_amount.toLocaleString()}
</TableCell>
<TableCell className="p-2 text-center">
{!editMode && <TableCell className="p-2 text-center">
<Button
variant="ghost"
size="icon"
@@ -955,7 +1024,7 @@ export default function OutboundPage() {
>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableCell>}
</TableRow>
))}
</TableBody>
@@ -43,6 +43,7 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Plus,
Trash2,
@@ -51,6 +52,9 @@ import {
MapPin,
Building2,
Settings2,
Layers,
Info,
Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -135,6 +139,18 @@ export default function WarehouseManagementPage() {
const [locationForm, setLocationForm] = useState<Record<string, any>>({});
const [locationSaving, setLocationSaving] = useState(false);
// 모달: 랙 구조 일괄 등록
const [rackModalOpen, setRackModalOpen] = useState(false);
const [rackFloor, setRackFloor] = useState("");
const [rackZone, setRackZone] = useState("");
const [rackConditions, setRackConditions] = useState<
{ id: string; startRow: number; endRow: number; levels: number }[]
>([]);
const [rackLocationType, setRackLocationType] = useState("");
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
Record<string, { code: string; label: string }[]>
@@ -169,7 +185,7 @@ export default function WarehouseManagementPage() {
setCategoryOptions(whOpts);
const locOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["location_type", "status"]) {
for (const col of ["location_type", "status", "floor", "zone"]) {
try {
const res = await apiClient.get(
`/table-categories/${LOCATION_TABLE}/${col}/values`
@@ -461,6 +477,134 @@ export default function WarehouseManagementPage() {
}
};
// ─── 랙 구조 일괄 등록 ───
const openRackModal = () => {
setRackFloor("");
setRackZone("");
setRackConditions([]);
setRackLocationType("");
setRackStatus("");
setRackPreview([]);
setRackSaving(false);
setRackModalOpen(true);
};
const addRackCondition = () => {
const lastEnd = rackConditions.length > 0
? rackConditions[rackConditions.length - 1].endRow
: 0;
setRackConditions((prev) => [
...prev,
{ id: crypto.randomUUID(), startRow: lastEnd + 1, endRow: lastEnd + 1, levels: 1 },
]);
};
const updateRackCondition = (id: string, field: string, value: number) => {
setRackConditions((prev) =>
prev.map((c) => (c.id === id ? { ...c, [field]: value } : c))
);
};
const removeRackCondition = (id: string) => {
setRackConditions((prev) => prev.filter((c) => c.id !== id));
};
const generateRackPreview = () => {
if (!rackFloor.trim() || !rackZone.trim()) {
toast.error("층과 구역을 입력해주세요");
return;
}
if (rackConditions.length === 0) {
toast.error("조건을 1개 이상 추가해주세요");
return;
}
// 조건 유효성 검사
for (const cond of rackConditions) {
if (cond.startRow < 1 || cond.endRow < 1 || cond.levels < 1) {
toast.error("열 범위와 단 수는 1 이상이어야 합니다");
return;
}
if (cond.endRow < cond.startRow) {
toast.error("끝 열은 시작 열보다 크거나 같아야 합니다");
return;
}
}
const whCode = selectedWarehouse?.warehouse_code || "";
// 카테고리 코드→라벨 변환 (셀렉트에서 코드가 저장되므로)
const floorOpts = locationCategoryOptions["floor"] || [];
const zoneOpts = locationCategoryOptions["zone"] || [];
const floorLabel = floorOpts.find(o => o.code === rackFloor)?.label || rackFloor.trim();
const zoneLabel = zoneOpts.find(o => o.code === rackZone)?.label || rackZone.trim();
const floorCode = floorLabel.replace(/층$/, "");
const zoneCode = zoneLabel.replace(/구역$/, "");
const items: any[] = [];
for (const cond of rackConditions) {
for (let row = cond.startRow; row <= cond.endRow; row++) {
for (let level = 1; level <= cond.levels; level++) {
const rowStr = String(row).padStart(2, "0");
const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`;
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
items.push({
location_code: locationCode,
location_name: locationName,
warehouse_code: whCode,
floor: floorLabel,
zone: zoneLabel,
row_num: String(row),
level_num: String(level),
location_type: rackLocationType,
status: rackStatus,
});
}
}
}
setRackPreview(items);
};
const handleRackBulkSave = async () => {
if (rackPreview.length === 0) {
toast.error("미리보기를 먼저 생성해주세요");
return;
}
setRackSaving(true);
try {
let successCount = 0;
for (const item of rackPreview) {
await apiClient.post(
`/table-management/tables/${LOCATION_TABLE}/add`,
{ id: crypto.randomUUID(), ...item }
);
successCount++;
}
toast.success(`${successCount}개의 위치가 등록되었어요`);
setRackModalOpen(false);
fetchLocations();
} catch (err) {
toast.error("일괄 등록 중 오류가 발생했어요");
} finally {
setRackSaving(false);
}
};
// 랙 조건별 통계
const getRackConditionCount = (cond: { startRow: number; endRow: number; levels: number }) => {
if (cond.endRow < cond.startRow || cond.startRow < 1 || cond.levels < 1) return 0;
return (cond.endRow - cond.startRow + 1) * cond.levels;
};
// 랙 미리보기 통계
const rackStats = {
totalLocations: rackPreview.length,
totalRows: rackConditions.reduce((acc, c) => {
if (c.endRow >= c.startRow && c.startRow >= 1) return acc + (c.endRow - c.startRow + 1);
return acc;
}, 0),
maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0),
};
// 엑셀 내보내기
const handleExcelExport = () => {
if (warehouses.length === 0) {
@@ -655,6 +799,15 @@ export default function WarehouseManagementPage() {
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={openRackModal}
>
<Layers className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
@@ -1048,6 +1201,344 @@ export default function WarehouseManagementPage() {
</DialogContent>
</Dialog>
{/* 랙 구조 일괄 등록 Dialog */}
<Dialog open={rackModalOpen} onOpenChange={setRackModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-y-auto p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code})
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-160px)]">
<div className="space-y-6 px-6 py-4">
{/* 기본 정보 */}
<div>
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
📍
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={`${selectedWarehouse?.warehouse_name || ""} (${selectedWarehouse?.warehouse_code || ""})`}
disabled
/>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
{(locationCategoryOptions["floor"] || []).length > 0 ? (
<Select value={rackFloor} onValueChange={setRackFloor}>
<SelectTrigger>
<SelectValue placeholder="층 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["floor"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rackFloor}
onChange={(e) => setRackFloor(e.target.value)}
placeholder="예: B1, 1F, 2F"
/>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
{(locationCategoryOptions["zone"] || []).length > 0 ? (
<Select value={rackZone} onValueChange={setRackZone}>
<SelectTrigger>
<SelectValue placeholder="구역 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["zone"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rackZone}
onChange={(e) => setRackZone(e.target.value)}
placeholder="예: A, B, C"
/>
)}
</div>
</div>
</div>
{/* 랙 라인 구조 설정 */}
<div>
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
📊
</h3>
{/* 안내 박스 */}
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 mb-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" />
<div className="text-xs text-blue-700 dark:text-blue-300 space-y-0.5">
<p>1. </p>
<p>2. </p>
<p>3. 예시: 조건1(1~3, 3), 2(4~6, 5)</p>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground">
{rackConditions.length}
</span>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={addRackCondition}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{rackConditions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 border-2 border-dashed rounded-lg border-border">
<Layers className="h-8 w-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground mb-2">
</p>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={addRackCondition}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="space-y-2">
{rackConditions.map((cond, idx) => (
<div
key={cond.id}
className="flex items-center gap-3 rounded-lg border p-3 bg-card"
>
<span className="text-xs font-bold text-muted-foreground w-10 shrink-0">
{idx + 1}
</span>
<div className="flex items-center gap-1.5 flex-1">
<Input
type="number"
min={1}
className="h-8 w-20 text-center text-xs"
value={cond.startRow || ""}
onChange={(e) =>
updateRackCondition(cond.id, "startRow", Number(e.target.value) || 0)
}
placeholder="시작"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="number"
min={1}
className="h-8 w-20 text-center text-xs"
value={cond.endRow || ""}
onChange={(e) =>
updateRackCondition(cond.id, "endRow", Number(e.target.value) || 0)
}
placeholder="끝"
/>
<span className="text-xs text-muted-foreground">,</span>
<Input
type="number"
min={1}
className="h-8 w-16 text-center text-xs"
value={cond.levels || ""}
onChange={(e) =>
updateRackCondition(cond.id, "levels", Number(e.target.value) || 0)
}
placeholder="단"
/>
<span className="text-xs text-muted-foreground"></span>
</div>
{getRackConditionCount(cond) > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{cond.startRow} ~ {cond.endRow} × {cond.levels} = {getRackConditionCount(cond)}
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive shrink-0"
onClick={() => removeRackCondition(cond.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* 공통 설정 */}
<div>
<h3 className="text-sm font-bold mb-3 flex items-center gap-1.5">
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select value={rackLocationType} onValueChange={setRackLocationType}>
<SelectTrigger>
<SelectValue placeholder="위치유형 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["location_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select value={rackStatus} onValueChange={setRackStatus}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{(locationCategoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold flex items-center gap-1.5">
👁
</h3>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={generateRackPreview}
>
<Eye className="h-3.5 w-3.5" />
</Button>
</div>
{rackPreview.length > 0 && (
<>
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.totalLocations}</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.totalRows}</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-[11px] text-muted-foreground"> </p>
<p className="text-lg font-bold">{rackStats.maxLevels}</p>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="rounded-lg border overflow-hidden max-h-[300px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold text-muted-foreground">No</TableHead>
<TableHead className="text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-14 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-12 text-center text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rackPreview.map((item, idx) => (
<TableRow key={idx} className="text-xs">
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-mono">{item.location_code}</TableCell>
<TableCell>{item.location_name}</TableCell>
<TableCell className="text-center">{item.floor}</TableCell>
<TableCell className="text-center">{item.zone}</TableCell>
<TableCell className="text-center">{item.row_num}</TableCell>
<TableCell className="text-center">{item.level_num}</TableCell>
<TableCell>
{resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"}
</TableCell>
<TableCell>-</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
{rackPreview.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 border-2 border-dashed rounded-lg border-border text-sm text-muted-foreground">
</div>
)}
</div>
</div>
</ScrollArea>
<DialogFooter className="px-6 pb-6 pt-2 border-t">
<Button variant="outline" onClick={() => setRackModalOpen(false)}>
</Button>
<Button
onClick={handleRackBulkSave}
disabled={rackSaving || rackPreview.length === 0}
>
{rackSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
({rackPreview.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
@@ -27,13 +27,15 @@ import { TableSettingsModal } from "@/components/common/TableSettingsModal";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_standard", label: "검사기준" },
{ key: "inspection_item_name", label: "검사항목" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
{ key: "unit", label: "단위" },
{ key: "apply_type", label: "적용유형" },
{ key: "is_active", label: "사용여부" },
{ key: "apply_type", label: "적용구분" },
{ key: "manager", label: "관리자" },
];
const DEFECT_TABLE = "defect_standard_mng";
const EQUIPMENT_TABLE = "inspection_equipment_mng";
@@ -90,6 +92,7 @@ export default function InspectionManagementPage() {
/* ───── 카테고리 옵션 ───── */
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
useEffect(() => {
@@ -98,7 +101,14 @@ export default function InspectionManagementPage() {
const catList = [
{ table: INSPECTION_TABLE, col: "inspection_type" },
{ table: INSPECTION_TABLE, col: "apply_type" },
{ table: INSPECTION_TABLE, col: "inspection_method" },
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
{ table: INSPECTION_TABLE, col: "unit" },
{ table: DEFECT_TABLE, col: "defect_type" },
{ table: DEFECT_TABLE, col: "severity" },
{ table: DEFECT_TABLE, col: "inspection_type" },
{ table: DEFECT_TABLE, col: "is_active" },
{ table: EQUIPMENT_TABLE, col: "equipment_type" },
{ table: EQUIPMENT_TABLE, col: "equipment_status" },
];
await Promise.all(
@@ -112,21 +122,44 @@ export default function InspectionManagementPage() {
})
);
setCatOptions(optMap);
// 사용자 목록 로드
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
})));
} catch { /* skip */ }
};
load();
}, []);
const getCatLabel = (table: string, col: string, code: string) => {
if (!code) return "";
const opts = catOptions[`${table}.${col}`];
if (!opts) return code;
// 쉼표 구분 다중 코드 지원
if (code.includes(",")) {
return code.split(",").filter(Boolean).map(c => opts.find(o => o.code === c)?.label || c).join(", ");
}
return opts.find(o => o.code === code)?.label || code;
};
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
const MULTI_VALUE_COLUMNS = ["inspection_type"];
const fetchInspections = useCallback(async () => {
setInspLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const filters = searchFilters.map((f) => ({
columnName: f.columnName,
operator: MULTI_VALUE_COLUMNS.includes(f.columnName) ? "contains" : f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
@@ -190,7 +223,11 @@ export default function InspectionManagementPage() {
const openInspCreate = () => { setInspForm({}); setInspEditMode(false); setInspModalOpen(true); };
const openInspEdit = (row: any) => { setInspForm({ ...row }); setInspEditMode(true); setInspModalOpen(true); };
const saveInspection = async () => {
if (!inspForm.inspection_standard) { toast.error("검사기준은 필수 입력이에요"); return; }
if (!inspForm.inspection_code) { toast.error("검사코드는 필수예요"); return; }
if (!inspForm.inspection_type) { toast.error("유형을 1개 이상 선택해주세요"); return; }
if (!inspForm.inspection_criteria) { toast.error("검사기준은 필수예요"); return; }
if (!inspForm.inspection_item) { toast.error("검사항목은 필수예요"); return; }
if (!inspForm.judgment_criteria) { toast.error("판단기준은 필수예요"); return; }
setInspSaving(true);
try {
if (inspEditMode) {
@@ -225,7 +262,12 @@ export default function InspectionManagementPage() {
const openDefCreate = () => { setDefForm({}); setDefEditMode(false); setDefModalOpen(true); };
const openDefEdit = (row: any) => { setDefForm({ ...row }); setDefEditMode(true); setDefModalOpen(true); };
const saveDefect = async () => {
if (!defForm.defect_name) { toast.error("불량명은 필수 입력이에요"); return; }
if (!defForm.defect_code) { toast.error("불량코드는 필수예요"); return; }
if (!defForm.defect_type) { toast.error("불량유형은 필수예요"); return; }
if (!defForm.defect_name) { toast.error("불량명은 필수예요"); return; }
if (!defForm.severity) { toast.error("심각도는 필수예요"); return; }
if (!defForm.defect_content) { toast.error("불량내용은 필수예요"); return; }
if (!defForm.inspection_type) { toast.error("검사유형을 1개 이상 선택해주세요"); return; }
setDefSaving(true);
try {
if (defEditMode) {
@@ -257,10 +299,21 @@ export default function InspectionManagementPage() {
};
/* ═══════════════════ 검사장비 CRUD ═══════════════════ */
const openEqCreate = () => { setEqForm({}); setEqEditMode(false); setEqModalOpen(true); };
const openEqCreate = () => {
const maxNum = equipments
.map((e: any) => e.equipment_code || "")
.filter((c: string) => /^EQP-\d+$/.test(c))
.map((c: string) => parseInt(c.replace("EQP-", ""), 10))
.sort((a: number, b: number) => b - a)[0] || 0;
setEqForm({ equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}`, calibration_period: "12", equipment_status: "NORMAL" });
setEqEditMode(false);
setEqModalOpen(true);
};
const openEqEdit = (row: any) => { setEqForm({ ...row }); setEqEditMode(true); setEqModalOpen(true); };
const saveEquipment = async () => {
if (!eqForm.equipment_name) { toast.error("장비명은 필수 입력이에요"); return; }
if (!eqForm.equipment_code) { toast.error("장비코드는 필수예요"); return; }
if (!eqForm.equipment_name) { toast.error("장비명은 필수예요"); return; }
if (!eqForm.equipment_type) { toast.error("장비유형은 필수예요"); return; }
setEqSaving(true);
try {
if (eqEditMode) {
@@ -384,8 +437,10 @@ export default function InspectionManagementPage() {
</TableCell>
{ts.visibleColumns.map((col) => {
if (col.key === "inspection_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}</TableCell>;
if (col.key === "inspection_method") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "inspection_method", row.inspection_method)}</TableCell>;
if (col.key === "judgment_criteria") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "judgment_criteria", row.judgment_criteria)}</TableCell>;
if (col.key === "unit") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "unit", row.unit)}</TableCell>;
if (col.key === "apply_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}</TableCell>;
if (col.key === "is_active") return <TableCell key={col.key} className="text-center"><Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge></TableCell>;
return <TableCell key={col.key}>{row[col.key] ?? ""}</TableCell>;
})}
</TableRow>
@@ -430,37 +485,63 @@ export default function InspectionManagementPage() {
onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])}
/>
</TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{defLoading ? (
<TableRow><TableCell colSpan={5} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
<TableRow><TableCell colSpan={12} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
) : filteredDefects.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm"> </p></TableCell></TableRow>
) : filteredDefects.map((row) => (
<TableRow
key={row.id}
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
onClick={() => setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
onDoubleClick={() => openDefEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={defChecked.includes(row.id)} onCheckedChange={(v) => setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
</TableCell>
<TableCell>{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}</TableCell>
<TableCell>{row.defect_name}</TableCell>
<TableCell className="text-center">
<Badge variant={row.severity === "Critical" ? "destructive" : "secondary"} className="text-xs">{row.severity}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
</TableCell>
</TableRow>
))}
<TableRow><TableCell colSpan={12} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm"> </p></TableCell></TableRow>
) : filteredDefects.map((row) => {
const severityLabel = getCatLabel(DEFECT_TABLE, "severity", row.severity);
const severityColor = severityLabel === "치명적" ? "destructive" : severityLabel === "심각" ? "destructive" : severityLabel === "보통" ? "secondary" : "outline";
return (
<TableRow
key={row.id}
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
onClick={() => setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
onDoubleClick={() => openDefEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={defChecked.includes(row.id)} onCheckedChange={(v) => setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
</TableCell>
<TableCell className="font-semibold">{row.defect_code || "-"}</TableCell>
<TableCell><Badge variant="secondary" className="text-[10px]">{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}</Badge></TableCell>
<TableCell>{row.defect_name || "-"}</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">{row.defect_content || "-"}</TableCell>
<TableCell><Badge variant={severityColor as any} className="text-[10px]">{severityLabel || "-"}</Badge></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.inspection_type ? row.inspection_type.split(",").filter(Boolean).map((c: string) => (
<Badge key={c} variant="outline" className="text-[10px]">{getCatLabel(DEFECT_TABLE, "inspection_type", c)}</Badge>
)) : "-"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.apply_target ? row.apply_target.split(",").filter(Boolean).map((t: string) => (
<Badge key={t} variant="outline" className="text-[10px]">{t}</Badge>
)) : "-"}
</div>
</TableCell>
<TableCell><Badge variant={getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용" ? "default" : "secondary"} className="text-[10px]">{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}</TableCell>
<TableCell>{row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -501,37 +582,49 @@ export default function InspectionManagementPage() {
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])}
/>
</TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">()</TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eqLoading ? (
<TableRow><TableCell colSpan={7} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
<TableRow><TableCell colSpan={11} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
) : filteredEquipments.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm"> </p></TableCell></TableRow>
) : filteredEquipments.map((row) => (
<TableRow
key={row.id}
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
onClick={() => setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
onDoubleClick={() => openEqEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={eqChecked.includes(row.id)} onCheckedChange={(v) => setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
</TableCell>
<TableCell>{row.equipment_name}</TableCell>
<TableCell>{row.model_name}</TableCell>
<TableCell>{row.manufacturer}</TableCell>
<TableCell>{row.calibration_cycle}</TableCell>
<TableCell>{row.last_calibration_date}</TableCell>
<TableCell>{getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)}</TableCell>
</TableRow>
))}
<TableRow><TableCell colSpan={11} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm"> </p></TableCell></TableRow>
) : filteredEquipments.map((row) => {
const statusLabel = getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status);
const statusColor = statusLabel === "정상" ? "default" : statusLabel === "폐기" ? "destructive" : "secondary";
return (
<TableRow
key={row.id}
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
onClick={() => setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
onDoubleClick={() => openEqEdit(row)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={eqChecked.includes(row.id)} onCheckedChange={(v) => setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
</TableCell>
<TableCell className="font-semibold text-primary">{row.equipment_code || "-"}</TableCell>
<TableCell>{row.equipment_name || "-"}</TableCell>
<TableCell><Badge variant="secondary" className="text-[10px]">{getCatLabel(EQUIPMENT_TABLE, "equipment_type", row.equipment_type) || "-"}</Badge></TableCell>
<TableCell>{row.model_name || "-"}</TableCell>
<TableCell>{row.manufacturer || "-"}</TableCell>
<TableCell>{row.installation_location || "-"}</TableCell>
<TableCell>{row.last_calibration_date || "-"}</TableCell>
<TableCell>{row.calibration_period ? `${row.calibration_period}개월` : "-"}</TableCell>
<TableCell><Badge variant={statusColor as any} className="text-[10px]">{statusLabel || "-"}</Badge></TableCell>
<TableCell>{userOptions.find(u => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -541,62 +634,136 @@ export default function InspectionManagementPage() {
{/* ═══════════════════ 검사기준 모달 ═══════════════════ */}
<Dialog open={inspModalOpen} onOpenChange={setInspModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-[95vw] sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{inspEditMode ? "검사기준 수정" : "검사기준 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-4">
{/* 검사코드 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={inspForm.inspection_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, inspection_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={inspForm.inspection_code || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_code: e.target.value }))} placeholder="검사코드 입력" />
</div>
{/* 유형 (다중선택) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span> ()</Label>
<div className="flex flex-wrap gap-3 pt-1">
{(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map(o => {
const types: string[] = inspForm.inspection_type ? inspForm.inspection_type.split(",").filter(Boolean) : [];
const checked = types.includes(o.code);
return (
<div key={o.code} className="flex items-center gap-1.5">
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v ? [...types, o.code] : types.filter(t => t !== o.code);
setInspForm(p => ({ ...p, inspection_type: next.join(",") }));
}}
/>
<Label className="text-sm cursor-pointer">{o.label}</Label>
</div>
);
})}
</div>
</div>
{/* 검사기준 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={inspForm.inspection_criteria || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_criteria: e.target.value }))} placeholder="검사기준 입력" />
</div>
{/* 기준상세 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input className="h-9" value={inspForm.criteria_detail || ""} onChange={(e) => setInspForm(p => ({ ...p, criteria_detail: e.target.value }))} placeholder="기준상세 입력" />
</div>
{/* 검사항목 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={inspForm.inspection_item || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_item: e.target.value }))} placeholder="검사항목 입력" />
</div>
{/* 검사방법 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={inspForm.inspection_method || "__none__"} onValueChange={(v) => setInspForm(p => ({ ...p, inspection_method: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map(o => (
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.inspection_method`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 판단기준 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={inspForm.apply_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, apply_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Select value={inspForm.judgment_criteria || "__none__"} onValueChange={(v) => setInspForm(p => ({ ...p, judgment_criteria: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 단위 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={inspForm.unit || "__none__"} onValueChange={(v) => setInspForm(p => ({ ...p, unit: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.unit`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 적용구분 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={inspForm.apply_type || "__none__"} onValueChange={(v) => setInspForm(p => ({ ...p, apply_type: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${INSPECTION_TABLE}.apply_type`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={inspForm.inspection_standard || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={inspForm.inspection_item_name || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" />
</div>
{/* 관리자 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={inspForm.inspection_method || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={inspForm.unit || ""} onChange={(e) => setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" />
<Label className="text-xs font-semibold"></Label>
<Select value={inspForm.manager || "__none__"} onValueChange={(v) => setInspForm(p => ({ ...p, manager: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map(u => (
<SelectItem key={u.code} value={u.code}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={inspForm.is_active ?? true} onCheckedChange={(v) => setInspForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
</div>
<Label className="text-xs font-semibold"></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 resize-y"
value={inspForm.remark || ""}
onChange={(e) => setInspForm(p => ({ ...p, remark: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInspModalOpen(false)}></Button>
<Button onClick={saveInspection} disabled={inspSaving}>
{inspSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
@@ -604,50 +771,163 @@ export default function InspectionManagementPage() {
{/* ═══════════════════ 불량관리 모달 ═══════════════════ */}
<Dialog open={defModalOpen} onOpenChange={setDefModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-[95vw] sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{defEditMode ? "불량유형 수정" : "불량유형 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-4">
{/* 불량코드 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={defForm.defect_type || ""} onValueChange={(v) => setDefForm(p => ({ ...p, defect_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={defForm.defect_code || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_code: e.target.value }))} placeholder="불량코드 입력" />
</div>
{/* 불량유형 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Select value={defForm.defect_type || "__none__"} onValueChange={(v) => setDefForm(p => ({ ...p, defect_type: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.defect_type`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 불량명 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={defForm.severity || ""} onValueChange={(v) => setDefForm(p => ({ ...p, severity: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={defForm.defect_name || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명 입력" />
</div>
{/* 심각도 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Select value={defForm.severity || "__none__"} onValueChange={(v) => setDefForm(p => ({ ...p, severity: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Critical">Critical</SelectItem>
<SelectItem value="Major">Major</SelectItem>
<SelectItem value="Minor">Minor</SelectItem>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.severity`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 불량내용 */}
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={defForm.defect_name || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명을 입력해주세요" />
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] resize-y"
value={defForm.defect_content || ""}
onChange={(e) => setDefForm(p => ({ ...p, defect_content: e.target.value }))}
placeholder="불량 상세 내용 및 정의를 입력하세요"
rows={3}
/>
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={defForm.is_active ?? true} onCheckedChange={(v) => setDefForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
{/* 검사유형 (다중선택) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span> ()</Label>
<div className="flex flex-wrap gap-3 rounded-md border p-3">
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map(o => {
const types: string[] = defForm.inspection_type ? defForm.inspection_type.split(",").filter(Boolean) : [];
const checked = types.includes(o.code);
return (
<div key={o.code} className="flex items-center gap-1.5">
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v ? [...types, o.code] : types.filter(t => t !== o.code);
setDefForm(p => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
}}
/>
<Label className="text-sm cursor-pointer">{o.label}</Label>
</div>
);
})}
</div>
</div>
{/* 적용대상 (다중선택, 검사유형별 동적) */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> ()</Label>
<div className="rounded-md border p-3 min-h-[60px]">
{(() => {
const selectedTypes = defForm.inspection_type ? defForm.inspection_type.split(",").filter(Boolean) : [];
if (selectedTypes.length === 0) return <p className="text-xs text-muted-foreground"> </p>;
const typeTargetMap: Record<string, string[]> = {};
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
for (const code of selectedTypes) {
const label = defInspOpts.find(o => o.code === code)?.label || "";
if (label.includes("수입")) typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
else if (label.includes("공정")) typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
else if (label.includes("출하")) typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
}
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
<div key={typeName} className="mb-2 last:mb-0">
<p className="text-xs font-semibold border-b pb-1 mb-2">{typeName}</p>
<div className="flex flex-wrap gap-3">
{opts.map(t => (
<div key={t} className="flex items-center gap-1.5">
<Checkbox
checked={targets.includes(t)}
onCheckedChange={(v) => {
const next = v ? [...targets, t] : targets.filter(x => x !== t);
setDefForm(p => ({ ...p, apply_target: next.join(",") }));
}}
/>
<Label className="text-sm cursor-pointer">{t}</Label>
</div>
))}
</div>
</div>
));
})()}
</div>
</div>
{/* 사용여부 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={defForm.is_active || "__none__"} onValueChange={(v) => setDefForm(p => ({ ...p, is_active: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 관리자 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={defForm.manager_id || "__none__"} onValueChange={(v) => setDefForm(p => ({ ...p, manager_id: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map(u => (
<SelectItem key={u.code} value={u.code}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold"></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] resize-y"
value={defForm.remarks || ""}
onChange={(e) => setDefForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDefModalOpen(false)}></Button>
<Button onClick={saveDefect} disabled={defSaving}>
{defSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
@@ -655,49 +935,102 @@ export default function InspectionManagementPage() {
{/* ═══════════════════ 검사장비 모달 ═══════════════════ */}
<Dialog open={eqModalOpen} onOpenChange={setEqModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-[95vw] sm:max-w-[720px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{eqEditMode ? "검사장비 수정" : "검사장비 등록"}</DialogTitle>
<DialogTitle>{eqEditMode ? "장비 수정" : "장비 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={eqForm.equipment_name || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명을 입력해주세요" />
<div className="grid grid-cols-3 gap-4">
{/* Row 1: 장비코드, 장비명, 장비유형 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={eqForm.equipment_code || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_code: e.target.value }))} placeholder="장비코드" disabled={eqEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={eqForm.model_name || ""} onChange={(e) => setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명" />
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={eqForm.equipment_name || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명 입력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={eqForm.manufacturer || ""} onChange={(e) => setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" />
<Label className="text-xs font-semibold"> <span className="text-destructive">*</span></Label>
<Select value={eqForm.equipment_type || "__none__"} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_type: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="장비유형 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${EQUIPMENT_TABLE}.equipment_type`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Row 2: 모델명, 제조사, 시리얼번호 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input className="h-9" value={eqForm.model_name || ""} onChange={(e) => setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명 입력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={eqForm.calibration_cycle || ""} onChange={(e) => setEqForm(p => ({ ...p, calibration_cycle: e.target.value }))} placeholder="예: 12개월" />
<Label className="text-xs font-semibold"></Label>
<Input className="h-9" value={eqForm.manufacturer || ""} onChange={(e) => setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사 입력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Label className="text-xs font-semibold"></Label>
<Input className="h-9" value={eqForm.serial_number || ""} onChange={(e) => setEqForm(p => ({ ...p, serial_number: e.target.value }))} placeholder="시리얼번호 입력" />
</div>
{/* Row 3: 설치장소, 최근교정일, 교정주기 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input className="h-9" value={eqForm.installation_location || ""} onChange={(e) => setEqForm(p => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소 입력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Input type="date" className="h-9" value={eqForm.last_calibration_date || ""} onChange={(e) => setEqForm(p => ({ ...p, last_calibration_date: e.target.value }))} />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={eqForm.equipment_status || ""} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_status: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"> ()</Label>
<Input type="number" className="h-9" value={eqForm.calibration_period || ""} onChange={(e) => setEqForm(p => ({ ...p, calibration_period: e.target.value }))} placeholder="12" />
</div>
{/* Row 4: 장비상태, 담당자 */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={eqForm.equipment_status || "__none__"} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_status: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="장비상태 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(catOptions[`${EQUIPMENT_TABLE}.equipment_status`] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold"></Label>
<Select value={eqForm.manager_id || "__none__"} onValueChange={(v) => setEqForm(p => ({ ...p, manager_id: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{userOptions.map(u => (
<SelectItem key={u.code} value={u.code}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div />
{/* Row 5: 비고 (full width) */}
<div className="space-y-1.5 col-span-3">
<Label className="text-xs font-semibold"></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] resize-y"
value={eqForm.remarks || ""}
onChange={(e) => setEqForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEqModalOpen(false)}></Button>
<Button onClick={saveEquipment} disabled={eqSaving}>
{eqSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -35,6 +35,24 @@ const GRID_COLUMNS = [
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "입고검사", matchLabels: ["입고검사", "수입검사", "입고", "수입"] },
{ key: "outgoing_inspection", label: "출고검사", matchLabels: ["출고검사", "출하검사", "출고", "출하"] },
{ key: "inventory_inspection", label: "재고검사", matchLabels: ["재고검사", "재고"] },
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
] as const;
type InspectionRow = {
id: string;
inspection_standard_id: string;
inspection_detail: string;
inspection_method: string;
apply_process: string;
acceptance_criteria: string;
is_required: boolean;
};
export default function ItemInspectionInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -52,27 +70,85 @@ export default function ItemInspectionInfoPage() {
const [saving, setSaving] = useState(false);
/* FK 옵션 */
const [itemOptions, setItemOptions] = useState<{ code: string; label: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string }[]>([]);
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
useEffect(() => {
const loadOptions = async () => {
try {
const [itemRes, inspRes] = await Promise.all([
const [itemRes, inspRes, userRes] = await Promise.all([
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
]);
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
setItemOptions(items.map((r: any) => ({ code: r.item_code, label: `${r.item_code} - ${r.item_name || ""}` })));
setItemOptions(items.map((r: any) => ({
code: r.item_number || r.item_code || "",
name: r.item_name || "",
item_type: r.type || r.item_type || "",
unit: r.unit || "",
})));
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
setInspOptions(insps.map((r: any) => ({ code: r.id, label: r.inspection_standard || r.id })));
setInspOptions(insps.map((r: any) => ({
code: r.id,
label: r.inspection_criteria || r.inspection_standard || r.id,
detail: r.inspection_item || r.inspection_criteria || "",
method: r.inspection_method || "",
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
if (catRes.data?.data?.length) flatten(catRes.data.data);
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
})));
} catch { /* skip */ }
};
loadOptions();
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
};
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchData = useCallback(async () => {
setLoading(true);
@@ -96,20 +172,82 @@ export default function ItemInspectionInfoPage() {
useEffect(() => { fetchData(); }, [fetchData]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setModalOpen(true); };
const openEdit = (row: any) => { setForm({ ...row }); setEditMode(true); setModalOpen(true); };
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = (row: any) => {
setForm({ ...row });
setEditMode(true);
// 저장된 검사항목 rows 복원
const saved = row.inspection_items || {};
setInspectionRows(saved);
setCollapsedTypes({});
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), {
id: crypto.randomUUID(),
inspection_standard_id: "",
inspection_detail: "",
inspection_method: "",
apply_process: "",
acceptance_criteria: "",
is_required: false,
}],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
const saveData = { ...form, inspection_items: inspectionRows };
setSaving(true);
try {
if (editMode) {
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: form.id }, updatedData: form,
originalData: { id: form.id }, updatedData: saveData,
});
toast.success("품목검사정보를 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...form });
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...saveData });
toast.success("품목검사정보를 등록했어요");
}
setModalOpen(false);
@@ -209,71 +347,234 @@ export default function ItemInspectionInfoPage() {
</div>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={form.item_code || ""} onValueChange={(v) => {
const opt = itemOptions.find(o => o.code === v);
const name = opt ? opt.label.split(" - ").slice(1).join(" - ") : "";
setForm(p => ({ ...p, item_code: v, item_name: name }));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="품목을 선택해주세요" /></SelectTrigger>
<SelectContent>
{itemOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목 선택 시 자동입력" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.inspection_standard_id || ""} onValueChange={(v) => {
const opt = inspOptions.find(o => o.code === v);
setForm(p => ({ ...p, inspection_standard_id: v, inspection_standard_name: opt?.label || "" }));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="검사기준을 선택해주세요" /></SelectTrigger>
<SelectContent>
{inspOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={form.inspection_level || ""} onChange={(e) => setForm(p => ({ ...p, inspection_level: e.target.value }))} placeholder="검사수준" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={form.sampling_method || ""} onChange={(e) => setForm(p => ({ ...p, sampling_method: e.target.value }))} placeholder="샘플링방법" />
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={form.is_active ?? true} onCheckedChange={(v) => setForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
<>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
</TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</TableCell>
<TableCell className="p-1 text-center">
<Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} />
</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}