Merge pull request 'jskim-node' (#18) from jskim-node into main

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/18
This commit is contained in:
jskim
2026-04-10 08:33:18 +00:00
59 changed files with 3731 additions and 2689 deletions
+37 -1
View File
@@ -924,12 +924,26 @@ export const previewFile = async (
);
res.setHeader("Access-Control-Allow-Credentials", "true");
// 캐시 헤더 설정
// Cross-Origin-Resource-Policy: cross-origin 설정
// helmet 기본값(same-origin)을 오버라이드하여 v1.vexplor.com에서 api.vexplor.com 이미지 로드 허용
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
// 파일 크기 및 캐시 헤더 설정
const stat = fs.statSync(finalPath);
res.setHeader("Content-Length", stat.size);
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Content-Type", mimeType);
// 파일 스트림으로 전송
const fileStream = fs.createReadStream(finalPath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("파일 미리보기 오류:", error);
@@ -1031,9 +1045,20 @@ export const downloadFile = async (
`attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
const stat = fs.statSync(filePath);
res.setHeader("Content-Length", stat.size);
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("파일 다운로드 오류:", error);
@@ -1218,10 +1243,21 @@ export const getFileByToken = async (req: Request, res: Response) => {
"Content-Disposition",
`inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
const stat = fs.statSync(filePath);
res.setHeader("Content-Length", stat.size);
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.on("error", (err) => {
console.error("파일 스트림 오류:", err);
if (!res.headersSent) {
res.status(500).json({ success: false, message: "파일 읽기 오류" });
} else {
res.end();
}
});
fileStream.pipe(res);
} catch (error) {
console.error("❌ 토큰 파일 접근 오류:", error);
@@ -226,11 +226,12 @@ export async function getMaterialStatus(
return res.json({ success: true, data: [] });
}
// 4) 재고 조회 (창고/위치별)
const stockPlaceholders = materialIds
// 4) 재고 조회 (창고/위치별) — inventory_stock.item_code는 item_number 기준
const materialCodes = materialIds.map((id) => materialMap[id].materialCode);
const stockPlaceholders = materialCodes
.map((_, i) => `$${i + 1}`)
.join(",");
const stockParams: any[] = [...materialIds];
const stockParams: any[] = [...materialCodes];
let stockParamIdx = materialIds.length + 1;
const stockConditions: string[] = [
@@ -94,7 +94,7 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
mold_code, mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
remarks, image_path, memo, warranty_shot_count,
} = req.body;
if (!mold_code || !mold_name) {
@@ -107,15 +107,16 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
id, company_code, mold_code, mold_name, mold_type, category,
manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
operation_status, remarks, image_path, memo, warranty_shot_count, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW())
RETURNING *
`;
const params = [
companyCode, mold_code, mold_name, mold_type || null, category || null,
manufacturer || null, manufacturing_number || null, manufacturing_date || null,
cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null,
warranty_shot_count || 0, userId,
];
const result = await query(sql, params);
@@ -139,7 +140,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
remarks, image_path, memo, warranty_shot_count,
} = req.body;
const sql = `
@@ -153,8 +154,9 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
base_input_qty = COALESCE($10, base_input_qty),
operation_status = COALESCE($11, operation_status),
remarks = $12, image_path = $13, memo = $14,
warranty_shot_count = $15,
updated_date = NOW()
WHERE mold_code = $15 AND company_code = $16
WHERE mold_code = $16 AND company_code = $17
RETURNING *
`;
const params = [
@@ -162,7 +164,7 @@ export async function updateMold(req: AuthenticatedRequest, res: Response): Prom
manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo,
moldCode, companyCode,
warranty_shot_count || 0, moldCode, companyCode,
];
const result = await query(sql, params);
@@ -116,12 +116,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response)
const userId = req.user!.userId;
const { menuObjid, ...value } = req.body;
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "menuObjid는 필수입니다",
});
}
// menuObjid는 선택사항 — 옵션설정 등 전역 관리 화면에서는 없을 수 있음
logger.info("카테고리 값 추가 요청", {
tableName: value.tableName,
@@ -134,7 +129,7 @@ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response)
value,
companyCode,
userId,
Number(menuObjid) // ← menuObjid 전달
menuObjid ? Number(menuObjid) : null
);
return res.status(201).json({
@@ -269,7 +269,7 @@ class TableCategoryValueService {
value: TableCategoryValue,
companyCode: string,
userId: string,
menuObjid: number
menuObjid: number | null
): Promise<TableCategoryValue> {
const pool = getPool();
@@ -286,29 +286,35 @@ class TableCategoryValueService {
let duplicateQuery: string;
let duplicateParams: any[];
const menuCondition = menuObjid
? "AND menu_objid = $4"
: "AND menu_objid IS NULL";
const baseParams = menuObjid
? [value.tableName, value.columnName, value.valueCode, menuObjid]
: [value.tableName, value.columnName, value.valueCode];
if (companyCode === "*") {
// 최고 관리자: 모든 회사에서 중복 체크
duplicateQuery = `
SELECT value_id
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
AND menu_objid = $4
${menuCondition}
`;
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid];
duplicateParams = baseParams;
} else {
// 일반 회사: 자신의 회사에서만 중복 체크
const companyIdx = menuObjid ? "$5" : "$4";
duplicateQuery = `
SELECT value_id
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
AND menu_objid = $4
AND company_code = $5
${menuCondition}
AND company_code = ${companyIdx}
`;
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode];
duplicateParams = [...baseParams, companyCode];
}
const duplicateResult = await pool.query(duplicateQuery, duplicateParams);
@@ -352,11 +358,11 @@ class TableCategoryValueService {
const insertQuery = `
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
value_id, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING
) VALUES ((SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -1104,7 +1104,16 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -15,9 +15,12 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
Settings2, GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@@ -84,6 +87,49 @@ const GRID_COLUMNS_CONFIG = [
{ key: "memo", label: "메모" },
];
const MODAL_DETAIL_COLUMNS = [
{ key: "item_code", label: "품번", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "w-[120px]" },
{ key: "supplier", label: "공급업체", width: "w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "order_qty", label: "발주수량", width: "w-[90px]" },
{ key: "received_qty", label: "입고수량", width: "w-[90px]" },
{ key: "remain_qty", label: "잔량", width: "w-[80px]" },
{ key: "unit_price", label: "단가", width: "w-[100px]" },
{ key: "amount", label: "금액", width: "w-[100px]" },
{ key: "due_date", label: "납기일", width: "w-[160px]" },
{ key: "memo", label: "메모", width: "w-[120px]" },
];
const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16";
function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
col.width,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none cursor-grab active:cursor-grabbing hover:bg-muted-foreground/5 transition-colors"
)}
>
<div className="inline-flex items-center gap-1">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
<span className="truncate">{col.label}</span>
</div>
</TableHead>
);
}
export default function PurchaseOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -121,8 +167,43 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 모달 품목 테이블 컬럼 순서 (드래그 재정렬)
const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS);
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => {
const saved = localStorage.getItem(MODAL_COL_ORDER_KEY);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS;
const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key));
setModalColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, []);
const handleModalDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setModalColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key)));
return next;
});
};
const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소";
const visibleModalColumns = useMemo(() => {
return modalColumns.filter((col) => {
if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false;
return true;
});
}, [modalColumns, masterForm.input_mode]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@@ -1012,96 +1093,115 @@ export default function PurchaseOrderPage() {
</div>
) : (
<div className="border rounded-lg overflow-x-auto">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] 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-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{masterForm.input_mode === "itemFirst" && (
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
)}
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[60px] 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-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[160px] 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>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
<TableCell className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>
<TableCell className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>
{masterForm.input_mode === "itemFirst" && (
<TableCell>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
)}
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10">
<SortableContext items={visibleModalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow className="bg-muted hover:bg-muted">
{!isReadOnly && <TableHead className="w-[40px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{visibleModalColumns.map((col) => (
<SortableModalHead key={col.key} col={col} />
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
{!isReadOnly && (
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
<TableCell className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
<TableCell>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleModalColumns.map((col) => {
switch (col.key) {
case "item_code":
return <TableCell key={col.key} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>;
case "item_name":
return <TableCell key={col.key} className="text-[13px] max-w-[120px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>;
case "supplier":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"}</span>
) : (
<Select value={row.supplier_code || ""} onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find(o => o.code === v);
const name = supp?.label.replace(` (${v})`, "") || "";
updateDetailRow(idx, "supplier_code", v);
updateDetailRow(idx, "supplier_name", name);
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공급업체" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
);
case "spec":
return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>;
case "unit":
return <TableCell key={col.key} className="text-[13px]">{row.unit}</TableCell>;
case "order_qty":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.order_qty || "")} onChange={(e) => updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "received_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono text-muted-foreground">{row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}</TableCell>;
case "remain_qty":
return <TableCell key={col.key} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}</TableCell>;
case "unit_price":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs text-right font-mono block">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</span>
) : (
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono" />
)}
</TableCell>
);
case "amount":
return <TableCell key={col.key} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>;
case "due_date":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.due_date}</span>
) : (
<Input type="date" value={row.due_date || ""} onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
case "memo":
return (
<TableCell key={col.key}>
{isReadOnly ? (
<span className="text-xs">{row.memo}</span>
) : (
<Input value={row.memo || ""} onChange={(e) => updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs" />
)}
</TableCell>
);
default:
return <TableCell key={col.key} />;
}
})}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
</div>
)}
</div>
@@ -9,7 +9,7 @@
* ( supplier_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -137,7 +137,7 @@ function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
}
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -217,6 +217,13 @@ export default function PurchaseItemPage() {
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -340,38 +347,194 @@ export default function PurchaseItemPage() {
useEffect(() => { fetchItems(); }, [fetchItems]);
// 채번 미리보기
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
let endIdx = remaining.length;
if (meta.sep) {
if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
const patIdx = remaining.indexOf(meta.sep + "____");
if (patIdx >= 0) endIdx = patIdx;
} else {
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 파트 값으로부터 전체 코드 조합
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 채번 미리보기
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
const code = await loadNumberingPreview();
if (code) setFormData((prev) => ({ ...prev, item_number: code }));
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setFormData(prev => ({ ...prev, item_number: displayCode }));
}
};
// 수정 모달 열기
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장 (등록 또는 수정)
const handleSave = async () => {
if (!formData.item_name) {
@@ -388,8 +551,38 @@ export default function PurchaseItemPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -423,6 +616,7 @@ export default function PurchaseItemPage() {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -664,25 +858,53 @@ export default function PurchaseItemPage() {
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
supplier_item_code: row.supplier_item_code || "",
supplier_item_name: row.supplier_item_name || "",
}].filter((m) => m.supplier_item_code || m.supplier_item_name);
// 매핑 전체 조회
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.supplier_item_code || m.supplier_item_name)
.map((m: any) => ({
_id: `m_existing_${m.id}`,
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
}));
} catch { /* skip */ }
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
// 단가 전체 조회
let priceRows: any[] = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
end_date: p.end_date ? String(p.end_date).split("T")[0] : "",
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: p.base_price ? String(p.base_price) : "",
discount_type: p.discount_type || "",
discount_value: p.discount_value ? String(p.discount_value) : "",
rounding_type: p.rounding_type || "",
rounding_unit_value: p.rounding_unit_value || "",
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
}));
} catch { /* skip */ }
if (priceRows.length === 0) {
priceRows.push({
@@ -709,45 +931,102 @@ export default function PurchaseItemPage() {
const mappingRows = suppMappings[custKey] || [];
if (isEditingExisting && editSuppData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editSuppData.id },
updatedData: {
supplier_item_code: mappingRows[0]?.supplier_item_code || "",
supplier_item_name: mappingRows[0]?.supplier_item_name || "",
},
});
// 기존 매핑 조회
let existingMaps: any[] = [];
try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 기존 prices 삭제 후 재등록
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editSuppData.id;
for (let mi = 0; mi < mappingRows.length; mi++) {
const existMap = existingMaps[mi];
if (existMap) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: existMap.id },
updatedData: {
supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: mappingRows[mi].supplier_item_name || "",
},
});
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else {
const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
supplier_id: custKey, item_id: selectedItem.item_number,
supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: mappingRows[mi].supplier_item_name || "",
});
if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
}
}
// 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try {
const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editSuppData.id },
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
// 단가 upsert: 인덱스 기반
const priceRows = (suppPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: editSuppData.id,
supplier_id: custKey,
item_id: selectedItem.item_number,
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || editSuppData.id,
supplier_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: existPrice.id },
updatedData: priceData,
});
usedPriceIds.add(existPrice.id);
} else {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(), ...priceData,
});
}
}
// 초과분 delete
const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} else {
@@ -779,6 +1058,7 @@ export default function PurchaseItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -1216,7 +1496,71 @@ export default function PurchaseItemPage() {
{field.label}
{"required" in field && field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
{field.type === "numbering" ? (
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setFormData(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : field.type === "image" ? (
<ImageUpload
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
@@ -1260,8 +1604,7 @@ export default function PurchaseItemPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={"placeholder" in field ? field.placeholder : ("disabled" in field && field.disabled ? "자동 채번" : field.label)}
disabled={"disabled" in field && field.disabled && !isEditMode}
placeholder={"placeholder" in field ? field.placeholder : field.label}
className="h-9"
/>
)}
@@ -1380,7 +1723,7 @@ export default function PurchaseItemPage() {
{/* ── 공급업체 상세 입력/수정 모달 ── */}
<Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{editSuppData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1390,7 +1733,7 @@ export default function PurchaseItemPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedSuppsForDetail.map((cust, idx) => {
const custKey = cust.supplier_code || cust.id;
const mappingRows = suppMappings[custKey] || [];
@@ -971,6 +971,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -991,7 +992,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1043,6 +1045,7 @@ export default function SupplierManagementPage() {
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1092,7 +1095,8 @@ export default function SupplierManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -988,6 +988,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -1008,7 +1009,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -1063,6 +1065,7 @@ export default function CustomerManagementPage() {
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
@@ -1112,7 +1115,8 @@ export default function CustomerManagementPage() {
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
} catch { /* skip */ }
// 단가 upsert
@@ -1104,7 +1104,16 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
@@ -9,7 +9,7 @@
* ( customer_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -157,7 +157,7 @@ const ITEM_GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -241,6 +241,13 @@ export default function SalesItemPage() {
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
@@ -257,6 +264,7 @@ export default function SalesItemPage() {
calculated_price: string;
}>>>({});
const [editCustData, setEditCustData] = useState<any>(null);
const [collapsedPriceCards, setCollapsedPriceCards] = useState<Set<string>>(new Set());
// 카테고리 로드
@@ -361,6 +369,7 @@ export default function SalesItemPage() {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
@@ -611,6 +620,7 @@ export default function SalesItemPage() {
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
@@ -632,7 +642,8 @@ export default function SalesItemPage() {
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
@@ -673,45 +684,102 @@ export default function SalesItemPage() {
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 매핑 조회
let existingMaps: any[] = [];
try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 기존 prices 삭제 후 재등록
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editCustData.id;
for (let mi = 0; mi < mappingRows.length; mi++) {
const existMap = existingMaps[mi];
if (existMap) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: existMap.id },
updatedData: {
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
},
});
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else {
const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
}
}
// 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
// 단가 upsert: 인덱스 기반
const priceRows = (custPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || editCustData.id,
customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
originalData: { id: existPrice.id },
updatedData: priceData,
});
usedPriceIds.add(existPrice.id);
} else {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(), ...priceData,
});
}
}
// 초과분 delete
const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} else {
@@ -743,6 +811,7 @@ export default function SalesItemPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@@ -759,33 +828,154 @@ export default function SalesItemPage() {
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장 실패했습니다.");
console.error("거래처 상세 저장 실패:", err.response?.data);
const detail = err.response?.data?.error?.details;
const msg = err.response?.data?.message || "저장에 실패했습니다.";
toast.error(detail ? `${msg} (${typeof detail === "string" ? detail : JSON.stringify(detail)})` : msg);
} finally {
setSaving(false);
}
};
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
let endIdx = remaining.length;
if (meta.sep) {
if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
const patIdx = remaining.indexOf(meta.sep + "____");
if (patIdx >= 0) endIdx = patIdx;
} else {
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 파트 값으로부터 전체 코드 조합
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 채번 미리보기 로드
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 품목 등록 모달 열기
const openRegisterModal = async () => {
setEditItemForm({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setEditItemOpen(true);
const code = await loadNumberingPreview();
if (code) setEditItemForm(prev => ({ ...prev, item_number: code }));
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setEditItemForm(prev => ({ ...prev, item_number: displayCode }));
}
};
// 품목 수정 모달 열기
@@ -794,11 +984,49 @@ export default function SalesItemPage() {
if (!target) return;
const raw = rawItems.find((r) => r.id === target.id) || target;
setEditItemForm({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(target.id);
setEditItemOpen(true);
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(editItemForm, manualInputValue);
if (result.parts.length > 0) {
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS_FOR_MODAL.map(col => editItemForm[col])]);
// 수동 입력값 변경 시 preview 갱신
useEffect(() => {
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(editItemForm, manualInputValue);
if (result.parts.length > 0) {
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 품목 저장 (등록 + 수정 통합)
const handleEditSave = async () => {
if (!editItemForm.item_name) {
@@ -815,8 +1043,38 @@ export default function SalesItemPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = editItemForm.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData: editItemForm, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = editItemForm;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setEditItemOpen(false);
@@ -1238,7 +1496,71 @@ export default function SalesItemPage() {
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
{field.type === "numbering" ? (
isEditMode ? (
<Input
value={editItemForm[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setEditItemForm(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
<Input
value={editItemForm[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : field.type === "image" ? (
<ImageUpload
value={editItemForm[field.key] || ""}
onChange={(v) => setEditItemForm((prev) => ({ ...prev, [field.key]: v }))}
@@ -1402,7 +1724,7 @@ export default function SalesItemPage() {
{/* ── 거래처 상세 입력/수정 모달 ── */}
<Dialog open={custDetailOpen} onOpenChange={setCustDetailOpen}>
<DialogContent className="max-w-[1100px] max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{editCustData ? "수정" : "입력"} {selectedItem?.item_name || ""}
@@ -1412,7 +1734,7 @@ export default function SalesItemPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
@@ -1428,17 +1750,17 @@ export default function SalesItemPage() {
</span>
</div>
<div className="flex gap-4 p-4 bg-card">
<div className="flex gap-4 p-4 bg-card items-stretch">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/50">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 border rounded-lg p-4 bg-muted/50 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div className="space-y-2 min-h-[200px] max-h-[350px] overflow-y-auto flex-1">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : (
@@ -1478,35 +1800,61 @@ export default function SalesItemPage() {
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 border rounded-lg p-4 bg-muted/30 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-3 flex-1 overflow-y-auto max-h-[350px]">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
<div key={price._id} className="border rounded-lg bg-background overflow-hidden">
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setCollapsedPriceCards((prev) => {
const next = new Set(prev);
if (next.has(price._id)) next.delete(price._id); else next.add(price._id);
return next;
})}
>
<div className="flex items-center gap-2">
{collapsedPriceCards.has(price._id)
? <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{collapsedPriceCards.has(price._id) && price.calculated_price && (
<span className="text-xs text-muted-foreground ml-2">
{price.start_date || "—"} ~ {price.end_date || "—"} · <span className="font-mono font-bold text-foreground">{Number(price.calculated_price).toLocaleString()}</span> {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""}
</span>
)}
</div>
{prices.length > 1 && (
<Button
variant="ghost" size="sm"
className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}
onClick={(e) => { e.stopPropagation(); removePriceRow(custKey, price._id); }}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{!collapsedPriceCards.has(price._id) && <div className="px-3 pb-3 space-y-2">
{/* 기간 + 통화 */}
<div className="flex gap-2 items-center">
<Input
type="date"
value={price.start_date}
onChange={(e) => updatePriceRow(custKey, price._id, "start_date", e.target.value)}
onChange={(e) => {
const v = e.target.value;
updatePriceRow(custKey, price._id, "start_date", v);
if (price.end_date && v > price.end_date) {
updatePriceRow(custKey, price._id, "end_date", v);
}
}}
max={price.end_date || undefined}
className="h-8 text-xs flex-1"
/>
<span className="text-xs text-muted-foreground">~</span>
@@ -1514,6 +1862,7 @@ export default function SalesItemPage() {
type="date"
value={price.end_date}
onChange={(e) => updatePriceRow(custKey, price._id, "end_date", e.target.value)}
min={price.start_date || undefined}
className="h-8 text-xs flex-1"
/>
<div className="w-[80px]">
@@ -1608,6 +1957,7 @@ export default function SalesItemPage() {
{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}
</span>
</div>
</div>}
</div>
))}
</div>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -1104,7 +1104,16 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -905,24 +905,24 @@ export default function JeilGlassOrderPage() {
</div>
</div>
<div className="overflow-x-auto overflow-y-visible">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<Table className="min-w-[1800px]">
<TableHeader className="sticky top-0 bg-background z-20">
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead className="w-[36px]"></TableHead>
<TableHead className="w-[40px]">No</TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]">()</TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="w-[36px] sticky left-0 z-20 bg-background"></TableHead>
<TableHead className="w-[36px] sticky left-[36px] z-20 bg-background"></TableHead>
<TableHead className="w-[45px] sticky left-[72px] z-20 bg-background border-r">No</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]">()</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -950,26 +950,30 @@ export default function JeilGlassOrderPage() {
}
}}
>
<TableCell className="cursor-grab active:cursor-grabbing">
<TableCell className="cursor-grab active:cursor-grabbing sticky left-0 z-10 bg-background">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</TableCell>
<TableCell>
<TableCell className="sticky left-[36px] z-10 bg-background">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
<TableCell className="text-center text-xs text-muted-foreground sticky left-[72px] z-10 bg-background border-r">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → native select */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<select
value={row.division || ""}
onChange={(e) => updateDetailRow(idx, "division", e.target.value)}
className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""></option>
{(categoryOptions["item_division"] || []).map((o) => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
)}
</TableCell>
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
@@ -1033,7 +1037,10 @@ export default function JeilGlassOrderPage() {
{/* 합계 행 */}
{modalDetailRows.length > 0 && (
<TableRow className="bg-muted/30 font-semibold">
<TableCell colSpan={11} className="text-right text-sm"></TableCell>
<TableCell className="sticky left-0 z-10 bg-muted/30"></TableCell>
<TableCell className="sticky left-[36px] z-10 bg-muted/30"></TableCell>
<TableCell className="sticky left-[72px] z-10 bg-muted/30 border-r"></TableCell>
<TableCell colSpan={8} className="text-right text-sm"></TableCell>
<TableCell className="text-sm text-right">
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}
</TableCell>
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -1104,7 +1104,17 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
// 입력방식 변경 시 거래처 관련 값 초기화
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -1104,7 +1104,16 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
@@ -230,11 +230,10 @@ function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
const val = item.valueCode || item.value || item.name;
const lbl = item.valueLabel || item.label || item.name || val;
if (val) {
result.push({ value: val, label: lbl });
}
if (item.children?.length) walk(item.children);
}
@@ -777,12 +777,16 @@ export default function ReceivingPage() {
return;
}
if (!modalWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setSaving(true);
try {
const res = await createReceiving({
inbound_number: modalInboundNo,
inbound_date: modalInboundDate,
warehouse_code: modalWarehouse || undefined,
warehouse_code: modalWarehouse,
location_code: modalLocation || undefined,
inspector: modalInspector || undefined,
manager: modalManager || undefined,
@@ -1052,17 +1052,17 @@ export default function ProductionPlanManagementPage() {
};
return (
<Table style={{ tableLayout: "fixed" }}>
<Table style={{ minWidth: "900px" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[30px]">
<TableHead style={{ width: "30px", minWidth: "30px" }}>
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead style={{ width: "40px", minWidth: "40px" }} />
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<TableHead key={col.key} style={{ ...ts.thStyle(col.key), minWidth: "80px" }} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
{col.label}
</TableHead>
))}
@@ -188,9 +188,12 @@ export default function InspectionManagementPage() {
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
}
if (col.key === "manager") {
base.render = (v: any, row: any) => userOptions.find((u) => u.code === row.manager)?.label || row.manager || "-";
}
return base;
});
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ts.visibleColumns, catOptions, userOptions]); // eslint-disable-line react-hooks/exhaustive-deps
/* ═══════════════════ 데이터 조회 ═══════════════════ */
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
@@ -385,7 +388,7 @@ export default function InspectionManagementPage() {
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
const openDefCreate = async () => {
setDefForm({});
setDefForm({ is_active: "사용" });
setDefEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
@@ -867,20 +870,16 @@ export default function InspectionManagementPage() {
</TableCell>
<TableCell>
<Badge
variant={
getCatLabel(DEFECT_TABLE, "is_active", row.is_active) === "사용"
? "default"
: "secondary"
}
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
className="text-[10px]"
>
{getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"}
{row.is_active === "사용" || row.is_active === "true" ? "사용" : 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>{userOptions.find((u) => u.code === row.manager_id)?.label || row.manager_id || "-"}</TableCell>
<TableCell className="text-muted-foreground">{row.remarks || "-"}</TableCell>
</TableRow>
);
@@ -1442,19 +1441,15 @@ export default function InspectionManagementPage() {
<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 }))}
value={defForm.is_active || "사용"}
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: 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>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
@@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const TABLE_NAME = "item_inspection_info";
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "item_name", label: "품명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] },
@@ -61,24 +60,29 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// 우측 패널: 선택된 품목
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedTypeTab, setSelectedTypeTab] = useState<string>("");
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* FK 옵션 */
// FK 옵션
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 [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]);
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
// 검사유형별 검사항목 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>([]);
@@ -109,7 +113,7 @@ export default function ItemInspectionInfoPage() {
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 }[] = [];
@@ -118,6 +122,15 @@ export default function ItemInspectionInfoPage() {
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
// 검사방법 카테고리
try {
const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`);
const flatMethods: { code: string; label: string }[] = [];
const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } };
if (methodRes.data?.data?.length) flattenM(methodRes.data.data);
setInspMethodCatOptions(flatMethods);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
@@ -129,20 +142,12 @@ export default function ItemInspectionInfoPage() {
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
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)
));
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);
@@ -186,24 +191,45 @@ export default function ItemInspectionInfoPage() {
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
// 선택된 품목의 그룹 데이터
const selectedGroup = useMemo(() => {
if (!selectedItemCode) return null;
return groupedData.find(g => g.item_code === selectedItemCode) || null;
}, [selectedItemCode, groupedData]);
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
return inspOptions.find(o => o.code === id)?.label || id || "-";
}, [inspOptions]);
// 검사방법 코드 → 라벨
const resolveMethodLabel = useCallback((code: string) => {
return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-";
}, [inspMethodCatOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
const openEdit = async (itemCode?: string) => {
const code = itemCode || selectedItemCode;
if (!code) { toast.error("수정할 항목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === code);
if (!group) return;
const row = group.rows[0];
setForm({ ...row });
setEditMode(true);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
@@ -212,7 +238,6 @@ export default function ItemInspectionInfoPage() {
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
@@ -221,48 +246,34 @@ export default function ItemInspectionInfoPage() {
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
inspection_method: mLabel,
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
} catch { setInspectionRows({}); }
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,
}],
[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),
}));
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
@@ -270,34 +281,27 @@ export default function ItemInspectionInfoPage() {
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 || "" };
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel };
}
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);
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 toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
if (!form.item_code) { toast.error("품목코드는 필수요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
@@ -306,54 +310,29 @@ export default function ItemInspectionInfoPage() {
});
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); }
toast.success(editMode ? "수정했어요" : "등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -361,138 +340,231 @@ export default function ItemInspectionInfoPage() {
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!selectedItemCode) { toast.error("삭제할 목을 선택해주세요"); return; }
const group = groupedData.find(g => g.item_code === selectedItemCode);
if (!group) return;
const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) });
toast.success("삭제했어요");
setSelectedItemCode(null);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex h-full flex-col">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<div className="px-3 py-2.5 border-b bg-muted/50">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
extraActions={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
}
/>
</div>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{ts.groupData(groupedData).map((group) => {
if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null;
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map((r: any) => r.id);
const allChecked = groupIds.every((id: string) => checkedIds.includes(id));
const renderCell = (key: string) => {
switch (key) {
case "item_code": return <TableCell key={key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
);
case "item_count": return <TableCell key={key} className="text-sm text-center">{group.rows.filter((r: any) => r.inspection_standard_id).length}</TableCell>;
case "is_active": return (
<TableCell key={key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={key}>{(group as any)[key] ?? ""}</TableCell>;
}
};
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
{/* 검색 필터 */}
<div className="shrink-0 px-3 pt-3 pb-2">
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-inspection"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={totalCount}
/>
</div>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
{/* 좌우 분할 패널 */}
<div className="flex-1 min-h-0 px-3 pb-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* ═══════ 좌측: 품목 목록 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="text-[10px]">{groupedData.length}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-7 text-xs" onClick={openCreate}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground" style={ts.thStyle(col.key)}>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => (
<TableRow
key={group.item_code}
className={cn("cursor-pointer", selectedItemCode === group.item_code ? "bg-primary/10 border-l-2 border-l-primary" : "hover:bg-muted/50")}
onClick={() => {
setSelectedItemCode(group.item_code);
setSelectedTypeTab(group.types[0] || "");
}}
>
{ts.visibleColumns.map((col) => {
switch (col.key) {
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
case "inspection_type": return (
<TableCell key={col.key}>
<div className="flex gap-1 flex-wrap">
{group.types.map((t: string) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>
))}
</div>
</TableCell>
);
case "is_active": return (
<TableCell key={col.key}>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
);
default: return <TableCell key={col.key}>{(group as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="shrink-0 px-4 py-2 border-t text-xs text-muted-foreground">
{groupedData.length} ( )
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ═══════ 우측: 검사유형별 검사항목 ═══════ */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
</div>
{selectedGroup && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" /></Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /></Button>
</div>
)}
</div>
{!selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<ClipboardList className="h-8 w-8 mx-auto opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
{/* 검사유형 탭 */}
<div className="shrink-0 flex items-center gap-2 px-4 py-3">
{selectedGroup.types.map((type: string) => {
const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length;
return (
<button
key={type}
type="button"
onClick={() => setSelectedTypeTab(type)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
selectedTypeTab === type
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
)}
>
{type}
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[16px] rounded-full text-[10px] font-bold px-1",
selectedTypeTab === type ? "bg-primary-foreground/20 text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
)}>
{count}
</span>
</button>
);
})}
</div>
{/* 검사항목 상세 테이블 */}
<div className="flex-1 min-h-0 overflow-auto px-4 pb-4">
{selectedTypeTab && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">{selectedTypeTab}</Badge>
<span className="text-sm font-medium"> </span>
</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 h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
<TableCell className="text-xs py-2">{row.inspection_item_name || "-"}</TableCell>
<TableCell className="text-xs py-2">{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell className="text-xs py-2">{resolveMethodLabel(row.inspection_method)}</TableCell>
<TableCell className="text-xs py-2">{row.apply_process || "-"}</TableCell>
<TableCell className="text-xs py-2">
{row.judgment_criteria ? (
<Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-xs py-2 font-mono">{row.pass_criteria || "-"}</TableCell>
<TableCell className="text-xs py-2 text-center">
{row.is_required === "true" || row.is_required === true ? (
<Badge variant="destructive" className="text-[9px]"></Badge>
) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
<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 ? (
@@ -502,16 +574,8 @@ export default function ItemInspectionInfoPage() {
<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>
<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 className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
@@ -527,11 +591,7 @@ export default function ItemInspectionInfoPage() {
{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)}
>
<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>
@@ -541,9 +601,7 @@ export default function ItemInspectionInfoPage() {
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => setItemModalOpen(false)}></Button></DialogFooter>
</>
) : (
<>
@@ -554,14 +612,14 @@ export default function ItemInspectionInfoPage() {
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<h4 className="text-sm font-semibold"> </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>
<Label className="text-xs font-semibold text-muted-foreground"></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}>
@@ -571,7 +629,7 @@ export default function ItemInspectionInfoPage() {
<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" }))}>
<Select value={form.is_active === false || form.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
@@ -583,50 +641,32 @@ export default function ItemInspectionInfoPage() {
<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>
<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>
<h4 className="text-sm font-semibold"> </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 }))}
/>
<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)}
>
<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")} />
<span className="text-xs text-muted-foreground ml-auto">{(inspectionRows[key] || []).length}</span>
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
@@ -641,10 +681,10 @@ export default function ItemInspectionInfoPage() {
<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-[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"></TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
@@ -657,46 +697,20 @@ export default function ItemInspectionInfoPage() {
<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>
<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">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
<Input className="h-8 text-xs" value={row.acceptance_criteria} onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" 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">
<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>
<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>
))}
@@ -711,8 +725,7 @@ export default function ItemInspectionInfoPage() {
<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" />}
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
@@ -720,14 +733,7 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
</div>
);
}
@@ -905,24 +905,24 @@ export default function JeilGlassOrderPage() {
</div>
</div>
<div className="overflow-x-auto overflow-y-visible">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<Table className="min-w-[1800px]">
<TableHeader className="sticky top-0 bg-background z-20">
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead className="w-[36px]"></TableHead>
<TableHead className="w-[40px]">No</TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]">()</TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="w-[36px] sticky left-0 z-20 bg-background"></TableHead>
<TableHead className="w-[36px] sticky left-[36px] z-20 bg-background"></TableHead>
<TableHead className="w-[45px] sticky left-[72px] z-20 bg-background border-r">No</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]">()</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -950,26 +950,30 @@ export default function JeilGlassOrderPage() {
}
}}
>
<TableCell className="cursor-grab active:cursor-grabbing">
<TableCell className="cursor-grab active:cursor-grabbing sticky left-0 z-10 bg-background">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</TableCell>
<TableCell>
<TableCell className="sticky left-[36px] z-10 bg-background">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
<TableCell className="text-center text-xs text-muted-foreground sticky left-[72px] z-10 bg-background border-r">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → native select */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<select
value={row.division || ""}
onChange={(e) => updateDetailRow(idx, "division", e.target.value)}
className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""></option>
{(categoryOptions["item_division"] || []).map((o) => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
)}
</TableCell>
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
@@ -1033,7 +1037,10 @@ export default function JeilGlassOrderPage() {
{/* 합계 행 */}
{modalDetailRows.length > 0 && (
<TableRow className="bg-muted/30 font-semibold">
<TableCell colSpan={11} className="text-right text-sm"></TableCell>
<TableCell className="sticky left-0 z-10 bg-muted/30"></TableCell>
<TableCell className="sticky left-[36px] z-10 bg-muted/30"></TableCell>
<TableCell className="sticky left-[72px] z-10 bg-muted/30 border-r"></TableCell>
<TableCell colSpan={8} className="text-right text-sm"></TableCell>
<TableCell className="text-sm text-right">
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}
</TableCell>
+131 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, Suspense, useEffect, useCallback, useRef } from "react";
import React, { useState, Suspense, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@@ -262,6 +263,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return false;
});
const [hasPopMenus, setHasPopMenus] = useState(false);
const [hoveredCollapsedMenu, setHoveredCollapsedMenu] = useState<string | null>(null);
const toggleSidebarCollapse = () => {
setSidebarCollapsed((prev) => {
@@ -623,22 +625,73 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 축소 상태 메뉴 렌더링 (아이콘만, hover 시 오버레이 메뉴로 조작)
const collapsedMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCollapsedMouseEnter = useCallback((menuId: string) => {
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
setHoveredCollapsedMenu(menuId);
}, []);
const handleCollapsedMouseLeave = useCallback(() => {
hoverTimeoutRef.current = setTimeout(() => setHoveredCollapsedMenu(null), 150);
}, []);
const renderCollapsedMenu = (menu: any) => {
const isActive = isMenuActive(menu);
const hasActiveChild = menu.hasChildren && menu.children?.some((child: any) => isMenuActive(child));
const isHovered = hoveredCollapsedMenu === menu.id;
return (
<button
<div
key={menu.id}
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
isActive || hasActiveChild
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(menu)}
ref={(el) => { collapsedMenuRefs.current[menu.id] = el; }}
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
onMouseLeave={handleCollapsedMouseLeave}
>
{menu.icon}
</button>
<button
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
isActive || hasActiveChild
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(menu)}
>
{menu.icon}
</button>
{isHovered && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] min-w-[180px] rounded-lg border border-border bg-popover p-1.5 shadow-lg"
style={{
top: collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().top ?? 0,
left: (collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().right ?? 0) + 4,
}}
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
onMouseLeave={handleCollapsedMouseLeave}
>
<div className="mb-1 rounded-md px-2.5 py-1.5 text-xs font-semibold text-foreground">
{menu.name}
</div>
{menu.hasChildren && menu.children?.map((child: any) => (
<button
key={child.id}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground ${
isMenuActive(child) ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"
}`}
onClick={() => {
handleMenuClick(child);
setHoveredCollapsedMenu(null);
}}
>
{child.icon}
<span className="truncate">{child.name}</span>
</button>
))}
</div>,
document.body
)}
</div>
);
};
@@ -794,8 +847,69 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
)}
{/* 확장 상태 전용: 관리 회사, 모드 전환 버튼 */}
{(!isMobile && sidebarCollapsed) ? null : (
{/* 관리 회사, 모드 전환 버튼 */}
{(!isMobile && sidebarCollapsed) ? (
/* 축소 상태: 관리자 아이콘 + hover 팝오버 */
((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") ? (
<div
className="flex flex-col items-center gap-1 border-b border-border py-2"
ref={(el) => { collapsedMenuRefs.current["__admin__"] = el; }}
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
onMouseLeave={handleCollapsedMouseLeave}
>
<button
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors ${
isAdminMode
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400"
: "text-primary bg-primary/10"
}`}
onClick={handleModeSwitch}
>
{isAdminMode ? <UserCheck className="h-5 w-5" /> : <Shield className="h-5 w-5" />}
</button>
{hoveredCollapsedMenu === "__admin__" && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] min-w-[200px] rounded-lg border border-border bg-popover p-2 shadow-lg"
style={{
top: collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().top ?? 0,
left: (collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().right ?? 0) + 4,
}}
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
onMouseLeave={handleCollapsedMouseLeave}
>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<div className="mb-2 rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-muted-foreground text-[10px]"> </p>
<p className="truncate text-sm font-semibold">{currentCompanyName || "로딩 중..."}</p>
</div>
)}
<Button
onClick={handleModeSwitch}
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer ${
isAdminMode
? "border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
: "border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 border"
}`}
>
{isAdminMode ? <><UserCheck className="h-4 w-4" /> </> : <><Shield className="h-4 w-4" /> </>}
</Button>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<Button
onClick={() => { setShowCompanySwitcher(true); setHoveredCollapsedMenu(null); }}
className="border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 mt-1.5 flex w-full items-center justify-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer"
>
<Building2 className="h-4 w-4" />
</Button>
)}
</div>,
document.body
)}
</div>
) : null
) : (
<>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<div className="border-border bg-muted/50 mx-3 mt-3 rounded-md border p-3">
@@ -885,7 +999,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
{/* 테마 토글 */}
{(!isMobile && sidebarCollapsed) ? null : (
{(!isMobile && sidebarCollapsed) ? (
<div className="flex items-center justify-center border-t border-border py-2">
<ThemeToggle collapsed />
</div>
) : (
<div className="border-border border-t px-3 py-1">
<ThemeToggle />
</div>
+2 -2
View File
@@ -32,10 +32,10 @@ export function ThemeToggle({ collapsed = false }: ThemeToggleProps) {
variant="ghost"
size={collapsed ? "icon" : "default"}
onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-full justify-start gap-2 text-sm"
className={collapsed ? "h-10 w-10 justify-center" : "w-full justify-start gap-2 text-sm"}
title={isDark ? "라이트 모드" : "다크 모드"}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDark ? <Sun className={collapsed ? "h-5 w-5" : "h-4 w-4"} /> : <Moon className={collapsed ? "h-5 w-5" : "h-4 w-4"} />}
{!collapsed && (isDark ? "라이트 모드" : "다크 모드")}
</Button>
);
@@ -43,9 +43,8 @@ function CardListRenderer({ component, getQueryResult }: CardRendererProps) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined
? String(row[item.fieldName])
: item.value;
const val = row[item.fieldName];
return val !== undefined && val !== null ? String(val) : "";
}
}
return item.value;
@@ -54,9 +54,12 @@ function getGridCellValue(
): string {
if (cell.cellType === "static") return cell.value ?? "";
if (cell.cellType === "field" && cell.field && row) {
const raw = String(row[cell.field] ?? "");
return applyNumberFormat(raw, cell.numberFormat, cell.currencySuffix);
if (cell.cellType === "field") {
if (cell.field && row) {
const raw = String(row[cell.field] ?? "");
return applyNumberFormat(raw, cell.numberFormat, cell.currencySuffix);
}
return ""; // 데이터 없으면 플레이스홀더 숨김
}
return cell.value ?? "";
@@ -277,7 +280,7 @@ function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps)
color: "#d1d5db",
}}
>
{col.field ? `{${col.field}}` : ""}
{""}
</td>
))}
</tr>
@@ -372,33 +372,35 @@ export function CategoryColumnList({
}
return (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex h-full flex-col">
<div className="shrink-0 space-y-1 px-3 pt-3">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="컬럼 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
<div className="shrink-0 px-3 pt-3">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="컬럼 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="space-y-1">
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 px-3 py-3">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
@@ -106,22 +106,13 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
const handleAddValue = async (newValue: TableCategoryValue) => {
try {
if (!menuObjid) {
toast({
title: "오류",
description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.",
variant: "destructive",
});
return;
}
const response = await addCategoryValue(
{
...newValue,
tableName,
columnName,
},
menuObjid
menuObjid || 0
);
if (response.success && response.data) {
+2 -2
View File
@@ -80,7 +80,7 @@ export async function getCategoryValues(
*/
export async function addCategoryValue(
value: TableCategoryValue,
menuObjid: number
menuObjid?: number
) {
try {
const response = await apiClient.post<{
@@ -88,7 +88,7 @@ export async function addCategoryValue(
data: TableCategoryValue;
}>("/table-categories/values", {
...value,
menuObjid, // ← menuObjid 포함
menuObjid: menuObjid || undefined,
});
return response.data;
} catch (error: any) {