Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
DDD1542
2026-04-03 17:01:08 +09:00
7 changed files with 2239 additions and 392 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}
File diff suppressed because it is too large Load Diff
@@ -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}