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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user