Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -201,13 +201,32 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_stock (
|
`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,
|
current_qty, safety_qty, last_out_date,
|
||||||
created_date, updated_date, writer
|
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]
|
[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 업데이트
|
// 판매출고인 경우 출하지시의 ship_qty 업데이트
|
||||||
|
|||||||
@@ -253,6 +253,25 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
|
[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 업데이트 — 기존 로직 유지
|
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
|
||||||
@@ -516,6 +535,25 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
|
|||||||
AND COALESCE(location_code, '') = COALESCE($5, '')`,
|
AND COALESCE(location_code, '') = COALESCE($5, '')`,
|
||||||
[inQty, companyCode, itemCode, whCode || '', locCode || '']
|
[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 기반
|
// 구매입고 발주 롤백: purchase_order_mng 기반
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const TAB_CONFIGS: TabConfig[] = [
|
|||||||
columns: [
|
columns: [
|
||||||
{ key: "carrier_code", label: "업체코드", width: "120px" },
|
{ key: "carrier_code", label: "업체코드", width: "120px" },
|
||||||
{ key: "carrier_name", label: "업체명", width: "160px" },
|
{ 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_person", label: "담당자", width: "100px" },
|
||||||
{ key: "contact_phone", label: "연락처", width: "130px" },
|
{ key: "contact_phone", label: "연락처", width: "130px" },
|
||||||
{ key: "email", label: "이메일", width: "180px" },
|
{ key: "email", label: "이메일", width: "180px" },
|
||||||
@@ -107,12 +107,12 @@ const TAB_CONFIGS: TabConfig[] = [
|
|||||||
formFields: [
|
formFields: [
|
||||||
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
|
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
|
||||||
{ key: "carrier_name", 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_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: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
|
||||||
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
|
{ 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: "상태를 선택해주세요" },
|
{ 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_start_date", label: "시작일", width: "110px" },
|
||||||
{ key: "contract_end_date", label: "종료일", width: "110px" },
|
{ key: "contract_end_date", label: "종료일", width: "110px" },
|
||||||
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
|
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
|
||||||
|
{ key: "contact_person", label: "담당자", width: "100px" },
|
||||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -163,6 +164,7 @@ const TAB_CONFIGS: TabConfig[] = [
|
|||||||
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
|
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
|
||||||
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
|
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
|
||||||
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
|
{ 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: "상태를 선택해주세요" },
|
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -184,9 +186,8 @@ const TAB_CONFIGS: TabConfig[] = [
|
|||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
|
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
|
||||||
{ key: "route_name", label: "구간명", type: "text", required: true, placeholder: "구간명을 입력해주세요" },
|
{ key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" },
|
||||||
{ key: "departure", label: "출발지", type: "text", placeholder: "출발지" },
|
{ key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" },
|
||||||
{ key: "destination", label: "도착지", type: "text", placeholder: "도착지" },
|
|
||||||
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
|
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
|
||||||
{ key: "avg_time_hours", label: "평균시간(h)", 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: "유형을 선택해주세요" },
|
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
|
||||||
@@ -202,19 +203,21 @@ const TAB_CONFIGS: TabConfig[] = [
|
|||||||
columns: [
|
columns: [
|
||||||
{ key: "vehicle_code", label: "차량코드", width: "120px" },
|
{ key: "vehicle_code", label: "차량코드", width: "120px" },
|
||||||
{ key: "vehicle_number", 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: "carrier_code", label: "운송업체", width: "120px" },
|
||||||
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
|
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
|
||||||
{ key: "driver_name", label: "운전자", width: "100px" },
|
{ key: "driver_name", label: "운전자", width: "100px" },
|
||||||
|
{ key: "last_maintenance_date", label: "최종정비일", width: "110px" },
|
||||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
|
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
|
||||||
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
|
{ 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: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||||
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
|
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
|
||||||
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
|
{ 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: "상태를 선택해주세요" },
|
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -433,16 +436,22 @@ export default function LogisticsInfoPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 배송구간: 출발지→도착지 로 구간명 자동 생성
|
||||||
|
const saveData = { ...formData };
|
||||||
|
if (activeTab === "route" && saveData.departure && saveData.destination) {
|
||||||
|
saveData.route_name = `${saveData.departure}→${saveData.destination}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (editMode && editId) {
|
if (editMode && editId) {
|
||||||
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
|
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
|
||||||
originalData: { id: editId },
|
originalData: { id: editId },
|
||||||
updatedData: formData,
|
updatedData: saveData,
|
||||||
});
|
});
|
||||||
toast.success("수정이 완료되었어요.");
|
toast.success("수정이 완료되었어요.");
|
||||||
} else {
|
} else {
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
`/table-management/tables/${config.tableName}/add`,
|
`/table-management/tables/${config.tableName}/add`,
|
||||||
{ id: crypto.randomUUID(), ...formData }
|
{ id: crypto.randomUUID(), ...saveData }
|
||||||
);
|
);
|
||||||
toast.success("등록이 완료되었어요.");
|
toast.success("등록이 완료되었어요.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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 { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
@@ -50,6 +51,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
|
|||||||
import {
|
import {
|
||||||
getOutboundList,
|
getOutboundList,
|
||||||
createOutbound,
|
createOutbound,
|
||||||
|
updateOutbound,
|
||||||
deleteOutbound,
|
deleteOutbound,
|
||||||
generateOutboundNumber,
|
generateOutboundNumber,
|
||||||
getOutboundWarehouses,
|
getOutboundWarehouses,
|
||||||
@@ -146,6 +148,10 @@ export default function OutboundPage() {
|
|||||||
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
|
const [selectedItems, setSelectedItems] = useState<SelectedSourceItem[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 수정 모드 (등록 모달을 재활용)
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [editItemIds, setEditItemIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 소스 데이터
|
// 소스 데이터
|
||||||
const [sourceKeyword, setSourceKeyword] = useState("");
|
const [sourceKeyword, setSourceKeyword] = useState("");
|
||||||
const [sourceLoading, setSourceLoading] = useState(false);
|
const [sourceLoading, setSourceLoading] = useState(false);
|
||||||
@@ -249,6 +255,8 @@ export default function OutboundPage() {
|
|||||||
|
|
||||||
const openRegisterModal = async () => {
|
const openRegisterModal = async () => {
|
||||||
const defaultType = "판매출고";
|
const defaultType = "판매출고";
|
||||||
|
setEditMode(false);
|
||||||
|
setEditItemIds([]);
|
||||||
setModalOutboundType(defaultType);
|
setModalOutboundType(defaultType);
|
||||||
setModalOutboundDate(new Date().toISOString().split("T")[0]);
|
setModalOutboundDate(new Date().toISOString().split("T")[0]);
|
||||||
setModalWarehouse("");
|
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 () => {
|
const searchSourceData = useCallback(async () => {
|
||||||
setSourcePage(1);
|
setSourcePage(1);
|
||||||
await loadSourceData(modalOutboundType, sourceKeyword || undefined);
|
await loadSourceData(modalOutboundType, sourceKeyword || undefined);
|
||||||
@@ -445,44 +490,67 @@ export default function OutboundPage() {
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await createOutbound({
|
if (editMode) {
|
||||||
outbound_number: modalOutboundNo,
|
// 수정 모드: 각 아이템별 update
|
||||||
outbound_date: modalOutboundDate,
|
await Promise.all(
|
||||||
warehouse_code: modalWarehouse || undefined,
|
selectedItems.map((item) =>
|
||||||
location_code: modalLocation || undefined,
|
updateOutbound(item.key, {
|
||||||
manager_id: modalManager || undefined,
|
outbound_date: modalOutboundDate,
|
||||||
memo: modalMemo || undefined,
|
outbound_qty: item.outbound_qty,
|
||||||
items: selectedItems.map((item) => ({
|
unit_price: item.unit_price,
|
||||||
outbound_type: item.outbound_type,
|
total_amount: item.total_amount,
|
||||||
reference_number: item.reference_number,
|
warehouse_code: modalWarehouse || undefined,
|
||||||
customer_code: item.customer_code,
|
location_code: modalLocation || undefined,
|
||||||
customer_name: item.customer_name,
|
manager_id: modalManager || undefined,
|
||||||
item_code: item.item_number,
|
memo: modalMemo || undefined,
|
||||||
item_name: item.item_name,
|
} as any)
|
||||||
spec: item.spec,
|
)
|
||||||
material: item.material,
|
);
|
||||||
unit: item.unit,
|
toast.success("출고 정보를 수정했어요");
|
||||||
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 || "출고 등록 완료");
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchList();
|
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 {
|
} catch {
|
||||||
alert("출고 등록 중 오류가 발생했습니다.");
|
toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 합계 계산
|
// 합계 계산
|
||||||
const totalSummary = useMemo(() => {
|
const totalSummary = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -593,6 +661,7 @@ export default function OutboundPage() {
|
|||||||
checkedIds.includes(row.id) && "bg-primary/5"
|
checkedIds.includes(row.id) && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleCheck(row.id)}
|
onClick={() => toggleCheck(row.id)}
|
||||||
|
onDoubleClick={() => openEditModal(row)}
|
||||||
>
|
>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="text-center"
|
className="text-center"
|
||||||
@@ -673,12 +742,12 @@ export default function OutboundPage() {
|
|||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<DialogContent className="flex flex-col gap-0 p-0 sm:max-w-[1600px] w-[95vw] h-[90vh] overflow-hidden">
|
<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">
|
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||||
<DialogTitle>출고 등록</DialogTitle>
|
<DialogTitle>{editMode ? "출고 수정" : "출고 등록"}</DialogTitle>
|
||||||
<DialogDescription>출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요.</DialogDescription>
|
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고유형</span>
|
||||||
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
||||||
<SelectTrigger className="h-9 w-[160px] text-sm">
|
<SelectTrigger className="h-9 w-[160px] text-sm">
|
||||||
@@ -699,13 +768,13 @@ export default function OutboundPage() {
|
|||||||
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
||||||
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
{/* 좌측: 소스 데이터 */}
|
{/* 좌측: 소스 데이터 (수정 모드에서는 숨김) */}
|
||||||
<ResizablePanel defaultSize={60} minSize={35}>
|
{!editMode && <ResizablePanel defaultSize={60} minSize={35}>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -809,12 +878,12 @@ export default function OutboundPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="flex h-full flex-col">
|
||||||
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
|
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
|
||||||
<h4 className="text-[13px] font-bold text-foreground">출고 정보</h4>
|
<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-[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-[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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -946,7 +1015,7 @@ export default function OutboundPage() {
|
|||||||
<TableCell className="p-2 text-right text-[13px] font-semibold">
|
<TableCell className="p-2 text-right text-[13px] font-semibold">
|
||||||
{item.total_amount.toLocaleString()}
|
{item.total_amount.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-2 text-center">
|
{!editMode && <TableCell className="p-2 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -955,7 +1024,7 @@ export default function OutboundPage() {
|
|||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -51,6 +52,9 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Building2,
|
Building2,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Layers,
|
||||||
|
Info,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
@@ -135,6 +139,18 @@ export default function WarehouseManagementPage() {
|
|||||||
const [locationForm, setLocationForm] = useState<Record<string, any>>({});
|
const [locationForm, setLocationForm] = useState<Record<string, any>>({});
|
||||||
const [locationSaving, setLocationSaving] = useState(false);
|
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<
|
const [categoryOptions, setCategoryOptions] = useState<
|
||||||
Record<string, { code: string; label: string }[]>
|
Record<string, { code: string; label: string }[]>
|
||||||
@@ -169,7 +185,7 @@ export default function WarehouseManagementPage() {
|
|||||||
setCategoryOptions(whOpts);
|
setCategoryOptions(whOpts);
|
||||||
|
|
||||||
const locOpts: Record<string, { code: string; label: string }[]> = {};
|
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 {
|
try {
|
||||||
const res = await apiClient.get(
|
const res = await apiClient.get(
|
||||||
`/table-categories/${LOCATION_TABLE}/${col}/values`
|
`/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 = () => {
|
const handleExcelExport = () => {
|
||||||
if (warehouses.length === 0) {
|
if (warehouses.length === 0) {
|
||||||
@@ -655,6 +799,15 @@ export default function WarehouseManagementPage() {
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
위치 등록
|
위치 등록
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1048,6 +1201,344 @@ export default function WarehouseManagementPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<TableSettingsModal
|
||||||
open={ts.open}
|
open={ts.open}
|
||||||
onOpenChange={ts.setOpen}
|
onOpenChange={ts.setOpen}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2,
|
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -35,6 +35,24 @@ const GRID_COLUMNS = [
|
|||||||
const ITEM_TABLE = "item_info";
|
const ITEM_TABLE = "item_info";
|
||||||
const INSPECTION_TABLE = "inspection_standard";
|
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() {
|
export default function ItemInspectionInfoPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
@@ -52,27 +70,85 @@ export default function ItemInspectionInfoPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
/* FK 옵션 */
|
/* FK 옵션 */
|
||||||
const [itemOptions, setItemOptions] = 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 }[]>([]);
|
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 옵션 로드 ═══════════════════ */
|
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
try {
|
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/${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/${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 || [];
|
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 || [];
|
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 */ }
|
} catch { /* skip */ }
|
||||||
};
|
};
|
||||||
loadOptions();
|
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 () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -96,20 +172,82 @@ export default function ItemInspectionInfoPage() {
|
|||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
/* ═══════════════════ CRUD ═══════════════════ */
|
/* ═══════════════════ CRUD ═══════════════════ */
|
||||||
const openCreate = () => { setForm({}); setEditMode(false); setModalOpen(true); };
|
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
|
||||||
const openEdit = (row: any) => { setForm({ ...row }); setEditMode(true); 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 () => {
|
const handleSave = async () => {
|
||||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||||
|
const saveData = { ...form, inspection_items: inspectionRows };
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||||
originalData: { id: form.id }, updatedData: form,
|
originalData: { id: form.id }, updatedData: saveData,
|
||||||
});
|
});
|
||||||
toast.success("품목검사정보를 수정했어요");
|
toast.success("품목검사정보를 수정했어요");
|
||||||
} else {
|
} 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("품목검사정보를 등록했어요");
|
toast.success("품목검사정보를 등록했어요");
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -209,71 +347,234 @@ export default function ItemInspectionInfoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
|
||||||
<DialogHeader>
|
{itemModalOpen ? (
|
||||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
<>
|
||||||
<DialogDescription>품목과 검사기준을 연결해주세요</DialogDescription>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>품목 선택</DialogTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<DialogDescription>품목코드 또는 품목명으로 검색</DialogDescription>
|
||||||
<div className="space-y-1.5 col-span-2">
|
</DialogHeader>
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
<div className="flex gap-2">
|
||||||
<Select value={form.item_code || ""} onValueChange={(v) => {
|
<Input
|
||||||
const opt = itemOptions.find(o => o.code === v);
|
className="h-9 flex-1"
|
||||||
const name = opt ? opt.label.split(" - ").slice(1).join(" - ") : "";
|
placeholder="품목코드 또는 품목명으로 검색"
|
||||||
setForm(p => ({ ...p, item_code: v, item_name: name }));
|
value={itemSearchKeyword}
|
||||||
}}>
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="품목을 선택해주세요" /></SelectTrigger>
|
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
|
||||||
<SelectContent>
|
/>
|
||||||
{itemOptions.map(o => (
|
<Button size="sm" className="h-9" onClick={handleItemSearch}>
|
||||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
<Search className="w-4 h-4 mr-1" />검색
|
||||||
))}
|
</Button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="border rounded-lg overflow-auto max-h-[50vh]">
|
||||||
</div>
|
<Table>
|
||||||
<DialogFooter>
|
<TableHeader className="sticky top-0 z-10">
|
||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<TableHead className="text-[11px] font-bold">품목코드</TableHead>
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||||
저장해요
|
<TableHead className="text-[11px] font-bold">품목유형</TableHead>
|
||||||
</Button>
|
<TableHead className="text-[11px] font-bold">단위</TableHead>
|
||||||
</DialogFooter>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<TableSettingsModal
|
<TableSettingsModal
|
||||||
open={ts.open}
|
open={ts.open}
|
||||||
onOpenChange={ts.setOpen}
|
onOpenChange={ts.setOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user