This commit is contained in:
SeongHyun Kim
2026-04-09 14:30:32 +09:00
186 changed files with 100844 additions and 4881 deletions
@@ -4108,6 +4108,7 @@ interface UserWithDeptRequest {
dept_name?: string;
position_code?: string;
position_name?: string;
end_date?: string | null;
};
mainDept?: {
dept_code: string;
@@ -4199,6 +4200,7 @@ export const saveUserWithDept = async (
dept_name: deptName,
position_code: userInfo.position_code,
position_name: positionName,
end_date: userInfo.end_date !== undefined ? (userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null) : undefined,
company_code: companyCode !== "*" ? companyCode : undefined,
};
@@ -4230,8 +4232,8 @@ export const saveUserWithDept = async (
email, tel, cell_phone, sabun,
user_type, user_type_name, status, locale,
dept_code, dept_name, position_code, position_name,
company_code, regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
company_code, end_date, regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW())`,
[
userInfo.user_id,
userInfo.user_name,
@@ -4250,6 +4252,7 @@ export const saveUserWithDept = async (
userInfo.position_code || null,
positionName,
companyCode !== "*" ? companyCode : null,
userInfo.end_date ? `${userInfo.end_date.substring(0, 10)}T00:00:00+09:00` : null,
]
);
}
@@ -256,11 +256,11 @@ export async function getPurchaseReportData(req: any, res: Response): Promise<vo
COALESCE(po.manager, '미지정') as manager,
COALESCE(po.status, '') as status,
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(pd.received_qty, ''), NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
CAST(COALESCE(NULLIF(pd.received_qty, ''), NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
pd.company_code
+29 -29
View File
@@ -843,45 +843,45 @@ export const previewFile = async (
return;
}
// 파일 경로에서 회사코드와 날짜 폴더 추출
const filePathParts = fileRecord.file_path!.split("/");
let fileCompanyCode = filePathParts[2] || "DEFAULT";
// company_* 처리 (실제 회사 코드로 변환)
if (fileCompanyCode === "company_*") {
fileCompanyCode = "company_*"; // 실제 디렉토리명 유지
}
// file_path의 /uploads/ 이후를 baseUploadDir과 직접 결합
const fileName = fileRecord.saved_file_name!;
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
let dateFolder = "";
if (filePathParts.length >= 6) {
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
const dbFilePath = fileRecord.file_path || "";
const uploadsIdx = dbFilePath.indexOf("/uploads/");
let finalPath: string;
if (uploadsIdx !== -1) {
const relativePath = dbFilePath.substring(uploadsIdx + "/uploads/".length);
finalPath = path.join(baseUploadDir, relativePath);
} else {
// fallback: 기존 방식
const filePathParts = dbFilePath.split("/");
let fileCompanyCode = filePathParts[2] || "DEFAULT";
if (fileCompanyCode === "company_*") {
fileCompanyCode = "company_*";
}
let dateFolder = "";
if (filePathParts.length >= 6) {
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
}
const companyUploadDir = getCompanyUploadDir(
fileCompanyCode,
dateFolder || undefined
);
finalPath = path.join(companyUploadDir, fileName);
}
const companyUploadDir = getCompanyUploadDir(
fileCompanyCode,
dateFolder || undefined
);
const filePath = path.join(companyUploadDir, fileName);
console.log("🔍 파일 미리보기 경로 확인:", {
objid: objid,
filePathFromDB: fileRecord.file_path,
companyCode: companyCode,
dateFolder: dateFolder,
fileName: fileName,
companyUploadDir: companyUploadDir,
finalFilePath: filePath,
fileExists: fs.existsSync(filePath),
finalFilePath: finalPath,
fileExists: fs.existsSync(finalPath),
});
if (!fs.existsSync(filePath)) {
console.error("❌ 파일 없음:", filePath);
if (!fs.existsSync(finalPath)) {
console.error("❌ 파일 없음:", finalPath);
res.status(404).json({
success: false,
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
message: `실제 파일을 찾을 수 없습니다: ${finalPath}`,
});
return;
}
@@ -929,7 +929,7 @@ export const previewFile = async (
res.setHeader("Content-Type", mimeType);
// 파일 스트림으로 전송
const fileStream = fs.createReadStream(filePath);
const fileStream = fs.createReadStream(finalPath);
fileStream.pipe(res);
} catch (error) {
console.error("파일 미리보기 오류:", error);
@@ -246,15 +246,33 @@ export async function create(req: AuthenticatedRequest, res: Response) {
);
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
const outQtyNum = Number(item.outbound_qty) || 0;
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.outbound_qty || 0, item.source_id, companyCode]
[outQtyNum, item.source_id, companyCode]
);
// 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트
const sidRes = await client.query(
`SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
const detailId = sidRes.rows[0]?.detail_id;
if (detailId) {
await client.query(
`UPDATE sales_order_detail
SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text,
balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[outQtyNum, detailId, companyCode]
);
}
}
}
@@ -332,8 +332,24 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[purchaseNo, companyCode]
);
const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고';
// 발주 헤더의 received_qty도 디테일 합계로 동기화
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
`UPDATE purchase_order_mng SET
status = $1,
received_qty = (
SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
remain_qty = (
SELECT CAST(COALESCE(SUM(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
);
@@ -9,6 +9,7 @@ import { encryptionService } from "../services/encryptionService";
import {
sendSmartFactoryLog,
getTodayPlanStatus,
planDailySends,
} from "../utils/smartFactoryLog";
/**
@@ -277,8 +278,9 @@ export const upsertSchedule = async (
]
);
// 계획은 매일 00:05에만 생성 (즉시 재생성하면 지난 시각 소급 전송 위험)
res.json({ success: true, message: "스케줄이 저장되었습니다. 내일 00:05부터 적용됩니다." });
// 스케줄 변경 후 오늘 계획 즉시 재생성 (이미 전송된 사용자는 자동 제외됨)
await planDailySends();
res.json({ success: true, message: "스케줄이 저장되었습니다." });
} catch (error) {
logger.error("스케줄 저장 실패:", error);
res.status(500).json({ success: false, message: "스케줄 저장 실패" });
@@ -46,20 +46,22 @@ export async function getOrderSummary(
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
SELECT DISTINCT ON (item_number)
item_number,
id AS item_id,
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
ORDER BY item_number, created_date DESC
),`
: `item_lead_time AS (
SELECT
SELECT DISTINCT ON (item_number)
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
ORDER BY item_number, created_date DESC
),`;
const query = `
@@ -97,6 +99,12 @@ export async function getOrderSummary(
WHERE sd.company_code = $1
AND sd.part_code IS NOT NULL AND sd.part_code != ''
),
distinct_item AS (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info
ORDER BY item_number, company_code, created_date DESC
),
order_summary AS (
SELECT
ao.part_code AS item_code,
@@ -107,7 +115,7 @@ export async function getOrderSummary(
COUNT(*) AS order_count,
MIN(ao.due_date) AS earliest_due_date
FROM all_orders ao
LEFT JOIN item_info ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code
LEFT JOIN distinct_item ii ON ao.part_code = ii.item_number AND ao.company_code = ii.company_code
GROUP BY ao.part_code, COALESCE(NULLIF(ao.part_name, ''), ii.item_name, ao.part_code)
),
${itemLeadTimeCte}
@@ -363,7 +371,7 @@ export async function updatePlan(
const query = `
UPDATE production_plan_mng
SET ${setClauses.join(", ")}
WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx}
WHERE id = $${paramIdx} AND company_code = $${paramIdx + 1}
RETURNING *
`;
@@ -142,15 +142,20 @@ export default function EquipmentInfoPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -272,8 +277,8 @@ export default function EquipmentInfoPage() {
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (isNumeric && !inspectionForm.unit) { toast.error("측정단위가 필수입니다."); return; }
// 기준값/오차범위 → 하한치/상한치 자동 계산
const saveData = { ...inspectionForm };
if (isNumeric && saveData.standard_value) {
@@ -739,7 +744,7 @@ export default function EquipmentInfoPage() {
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
const label = resolve("inspection_method", v);
const isNum = label === "숫자" || v === "숫자";
const isNum = ["숫자", "치수검사"].includes(label) || ["숫자", "치수검사"].includes(v);
if (!isNum) {
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
} else {
@@ -748,7 +753,7 @@ export default function EquipmentInfoPage() {
}, "점검방법")}</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
@@ -758,7 +763,7 @@ export default function EquipmentInfoPage() {
</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="grid grid-cols-2 gap-4">
@@ -333,69 +333,90 @@ export default function MaterialStatusPage() {
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
{ts.isVisible("status") && (
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</>
)}
</div>
</div>
</div>
</div>
))
);
})
)}
</div>
</div>
@@ -140,8 +140,16 @@ const DETAIL_HEADER_COLS = [
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10
const TOTAL_COLS = 10;
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -248,6 +256,31 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -900,8 +933,15 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "100px" }} /><col style={{ width: "120px" }} /><col style={{ width: "120px" }} /><col style={{ width: "100px" }} /><col style={{ width: "90px" }} /><col style={{ width: "120px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -942,8 +982,8 @@ export default function OutboundPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -1039,38 +1079,51 @@ export default function OutboundPage() {
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 출고유형 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
{/* 출고일 */}
<TableCell className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 거래처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 출고상태 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1084,7 +1137,7 @@ export default function OutboundPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1163,20 +1216,18 @@ export default function OutboundPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_code || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>
{/* 출고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -460,18 +460,20 @@ export default function PackagingPage() {
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<EDataTable
columns={[
{ key: "pkg_code", label: "품목코드" },
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형", width: "w-[80px]", render: (v) => PKG_TYPE_LABEL[v] || v || "-" },
{ key: "size", label: "크기(mm)", width: "w-[100px]", render: (_v, row) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
{ key: "max_load_kg", label: "최대중량", width: "w-[80px]", align: "right", render: (v) => Number(v || 0) > 0 ? `${v}kg` : "-" },
{ key: "status", label: "상태", width: "w-[60px]", align: "center", render: (v) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
] as EDataTableColumn<PkgUnit>[]}
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
pkg_type: { width: "w-[80px]", render: (v: any) => PKG_TYPE_LABEL[v] || v || "-" },
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
loading={pkgLoading}
@@ -117,12 +117,20 @@ const DETAIL_HEADER_COLS = [
{ key: "total_amount", label: "금액" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10
const TOTAL_COLS = 10;
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
@@ -278,6 +286,31 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -847,8 +880,15 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /><col style={{ width: "160px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -889,8 +929,8 @@ export default function ReceivingPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -985,38 +1025,51 @@ export default function ReceivingPage() {
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 입고유형 */}
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
{/* 입고일 */}
<TableCell className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 공급처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 입고상태 */}
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1030,7 +1083,7 @@ export default function ReceivingPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1108,20 +1161,18 @@ export default function ReceivingPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
{/* 입고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -491,12 +491,6 @@ export default function CompanyPage() {
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger
value="department"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Users className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
@@ -635,89 +629,6 @@ export default function CompanyPage() {
</div>
</TabsContent>
{/* ===================== Tab 2: 부서관리 ===================== */}
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
<div className="h-full overflow-hidden border rounded-none bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 트리 */}
<ResizablePanel defaultSize={30} minSize={20}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span></span>
<Badge variant="secondary" className="font-mono text-xs">{depts.length}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" className="h-8" onClick={openDeptRegister}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{deptLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : deptTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Building2 className="w-8 h-8 mb-2" />
<span className="text-sm"> </span>
</div>
) : (
renderTree(deptTree)
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={70} minSize={40}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}</Badge>}
</div>
{selectedDeptCode && (
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{selectedDeptCode ? (
<EDataTable
columns={companyMemberColumns}
data={members}
rowKey={(row) => row.user_id || row.id}
loading={memberLoading}
emptyMessage="소속 사원이 없어요"
emptyIcon={<Users className="w-8 h-8 mb-2" />}
onRowDoubleClick={(row) => openUserModal(row)}
showPagination={false}
draggableColumns={false}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Users className="w-10 h-10 mb-3" />
<span className="text-sm"> </span>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
@@ -9,7 +9,7 @@
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -279,6 +279,7 @@ export default function DepartmentPage() {
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
end_date: userForm.end_date || null,
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
@@ -308,41 +309,45 @@ export default function DepartmentPage() {
};
// 퇴사일 기반 재직/퇴사 분리
const today = new Date().toISOString().split("T")[0];
const _now = new Date();
const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
const isColVisible = (key: string) => ts.isVisible(key);
// EDataTable 컬럼 정의 (부서 목록)
const deptColumns: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
...(isColVisible("parent_dept_code")
? [{
key: "parent_dept_code",
label: "상위부서",
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
}]
: []),
...(isColVisible("status")
? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
}]
: []),
];
// EDataTable 컬럼 정의 (부서 목록) — ts.visibleColumns 순서를 따름
const deptColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
dept_code: { width: "w-[120px]" },
dept_name: { minWidth: "min-w-[140px]" },
parent_dept_code: {
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
},
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
},
};
// dept_code, dept_name은 항상 표시 (DEPT_COLUMNS에 포함되지 않으므로 visibleColumns에 없음)
const fixedCols: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", ...colProps["dept_code"] },
{ key: "dept_name", label: "부서명", ...colProps["dept_name"] },
];
const dynamicCols = ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
return [...fixedCols, ...dynamicCols];
}, [ts.visibleColumns]);
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -84,6 +84,56 @@ function CategoryCombobox({ options, value, onChange, placeholder }: {
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
@@ -108,7 +158,7 @@ const GRID_COLUMNS = [
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
@@ -137,6 +187,7 @@ export default function ItemInfoPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 검색 필터 (DynamicSearchFilter)
@@ -197,7 +248,7 @@ export default function ItemInfoPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
size: 99999,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -215,6 +266,7 @@ export default function ItemInfoPage() {
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
@@ -261,7 +313,8 @@ export default function ItemInfoPage() {
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -269,7 +322,8 @@ export default function ItemInfoPage() {
// 복사 모달 열기
const openCopyModal = async (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
@@ -459,6 +513,13 @@ export default function ItemInfoPage() {
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
@@ -115,17 +115,22 @@ export default function SubcontractorItemPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" });
if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" });
if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" });
if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
size: { width: "w-[90px]", render: (v) => v || "-" },
unit: { width: "w-[60px]", render: (v) => v || "-" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
status: { width: "w-[60px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
@@ -139,7 +139,7 @@ export default function ProductionPlanManagementPage() {
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
const [equipmentList, setEquipmentList] = useState<{ equipment_id: string; equipment_name: string }[]>([]);
const [equipmentList, setEquipmentList] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
// 선택/토글 상태
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
@@ -659,7 +659,7 @@ export default function ProductionPlanManagementPage() {
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId(plan.equipment_id ? String(plan.equipment_id) : "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setScheduleModalOpen(true);
}, []);
@@ -919,9 +919,7 @@ export default function ProductionPlanManagementPage() {
// 숫자 포맷
const formatNumber = (num: number | string) => Number(num).toLocaleString();
// 컬럼 표시 여부
const isColVisible = (key: string) => ts.isVisible(key);
const orderColSpan = 4 + ORDER_COLUMNS.filter((c) => isColVisible(c.key)).length;
// (컬럼 표시는 ts.visibleColumns 순서를 따름)
return (
<div className={cn("flex flex-col gap-3", isFullscreen ? "fixed inset-0 z-50 bg-background p-4" : "h-full p-3")}>
@@ -1019,6 +1017,38 @@ export default function ProductionPlanManagementPage() {
</div>
) : (
<div className="overflow-x-auto rounded-md border">
{(() => {
// 디테일 행에서 개별 값을 표시하는 컬럼 매핑
const DETAIL_VALUE_MAP: Record<string, string> = {
total_order_qty: "order_qty",
total_ship_qty: "ship_qty",
total_balance_qty: "balance_qty",
};
// 그룹 행에서 특수 렌더링이 필요한 컬럼
const renderGroupCell = (col: { key: string }, item: any) => {
if (col.key === "required_plan_qty") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
);
}
if (col.key === "lead_time") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
);
}
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item[col.key])}
</TableCell>
);
};
return (
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -1028,15 +1058,11 @@ export default function ProductionPlanManagementPage() {
<TableHead className="w-[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>
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("current_stock") && <TableHead style={ts.thStyle("current_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("safety_stock") && <TableHead style={ts.thStyle("safety_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("existing_plan_qty") && <TableHead style={ts.thStyle("existing_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("in_progress_qty") && <TableHead style={ts.thStyle("in_progress_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("required_plan_qty") && <TableHead style={ts.thStyle("required_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("lead_time") && <TableHead style={ts.thStyle("lead_time")} className="text-xs font-bold whitespace-nowrap text-right 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">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
@@ -1046,6 +1072,7 @@ export default function ProductionPlanManagementPage() {
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1068,25 +1095,14 @@ export default function ProductionPlanManagementPage() {
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}
{isColVisible("current_stock") && <TableCell style={ts.thStyle("current_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>}
{isColVisible("safety_stock") && <TableCell style={ts.thStyle("safety_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>}
{isColVisible("existing_plan_qty") && <TableCell style={ts.thStyle("existing_plan_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>}
{isColVisible("in_progress_qty") && <TableCell style={ts.thStyle("in_progress_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>}
{isColVisible("required_plan_qty") && (
<TableCell style={ts.thStyle("required_plan_qty")} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
)}
{isColVisible("lead_time") && (
<TableCell style={ts.thStyle("lead_time")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
)}
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
let remainColSpan = 0;
for (const col of ts.visibleColumns) {
if (!DETAIL_VALUE_MAP[col.key]) remainColSpan++;
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
@@ -1101,19 +1117,28 @@ export default function ProductionPlanManagementPage() {
</Badge>
</div>
</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right">{formatNumber(detail.order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right">{formatNumber(detail.ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right">{formatNumber(detail.balance_qty)}</TableCell>}
<TableCell colSpan={orderColSpan - 2 - (isColVisible("total_order_qty") ? 1 : 0) - (isColVisible("total_ship_qty") ? 1 : 0) - (isColVisible("total_balance_qty") ? 1 : 0)} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
{ts.visibleColumns.map((col) => {
const detailKey = DETAIL_VALUE_MAP[col.key];
if (detailKey) {
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right">{formatNumber(detail[detailKey])}</TableCell>;
}
return null;
})}
{remainColSpan > 0 && (
<TableCell colSpan={remainColSpan} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
)}
</TableRow>
))}
);
})}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
})()}
</div>
)}
</div>
@@ -1401,8 +1426,8 @@ export default function ProductionPlanManagementPage() {
<SelectContent>
<SelectItem value="none"></SelectItem>
{equipmentList.map((eq) => (
<SelectItem key={eq.equipment_id} value={String(eq.equipment_id)}>
{eq.equipment_name} ({eq.equipment_id})
<SelectItem key={eq.id} value={eq.equipment_code || eq.id}>
{eq.equipment_name} ({eq.equipment_code})
</SelectItem>
))}
</SelectContent>
@@ -742,10 +742,24 @@ export default function PurchaseOrderPage() {
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const detailCols = ts.visibleColumns.filter(c => !MASTER_KEYS.has(c.key));
const masterCols = ts.visibleColumns.filter(c => MASTER_KEYS.has(c.key));
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
@@ -753,23 +767,35 @@ export default function PurchaseOrderPage() {
return val || "-";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{ts.isVisible("purchase_no") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("order_date") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("supplier_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
</TableHead>
))}
{ts.isVisible("status") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>}
{ts.isVisible("memo") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
@@ -795,9 +821,7 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>}
{ts.isVisible("order_date") && <TableCell className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>}
{ts.isVisible("supplier_name") && <TableCell className="text-sm">{m.supplier_name || "-"}</TableCell>}
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
@@ -806,8 +830,7 @@ export default function PurchaseOrderPage() {
: ""}
</TableCell>
))}
{ts.isVisible("status") && <TableCell className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>}
{ts.isVisible("memo") && <TableCell className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
@@ -815,17 +838,14 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell />}
{ts.isVisible("order_date") && <TableCell />}
{ts.isVisible("supplier_name") && <TableCell />}
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{ts.isVisible("status") && <TableCell />}
{ts.isVisible("memo") && <TableCell />}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
@@ -617,17 +617,21 @@ export default function PurchaseItemPage() {
toast.success("다운로드 완료");
};
// EDataTable 컬럼 정의 (구매품목)
const itemColumns: EDataTableColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "standard_price", label: "구매단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// EDataTable 컬럼 정의 (구매품목) — ts.visibleColumns 기반
const COLUMN_RENDER_MAP: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]" },
size: { width: "w-[80px]" },
unit: { width: "w-[60px]" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]" },
status: { width: "w-[60px]" },
};
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
...COLUMN_RENDER_MAP[col.key],
}));
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -1229,47 +1229,44 @@ export default function SupplierManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const supplierColSpan = 1 + ["supplier_code", "supplier_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (공급업체 목록)
const supplierColumns: EDataTableColumn[] = [
...(isColumnVisible("supplier_code") ? [{ key: "supplier_code", label: "공급업체코드", width: "w-[120px]" }] : []),
...(isColumnVisible("supplier_name") ? [{ key: "supplier_name", label: "공급업체명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "공급업체유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (공급업체 목록) — ts.visibleColumns 순서를 따름
const supplierColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
supplier_code: { width: "w-[120px]" },
supplier_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -28,6 +28,7 @@ const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
@@ -420,18 +421,41 @@ export default function ItemInspectionInfoPage() {
<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>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{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) => {
{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 => r.id);
const allChecked = groupIds.every(id => checkedIds.includes(id));
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
@@ -445,21 +469,9 @@ export default function ItemInspectionInfoPage() {
<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>
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
<TableCell className="text-sm">{group.item_name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
<TableCell>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
{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 />
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -345,7 +345,8 @@ export default function CustomerManagementPage() {
if (!code) return "";
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const today = new Date().toISOString().split("T")[0];
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
// 품목 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
const grouped: Record<string, { master: any; details: any[] }> = {};
@@ -810,22 +811,26 @@ export default function CustomerManagementPage() {
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const SALES_CODES = ["CAT_ML8ZFVEL_1TOR"]; // 영업관리 카테고리 코드
setItemSearchResults(allItems.filter((item: any) => {
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => SALES_CODES.includes(code));
}));
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1229,47 +1234,44 @@ export default function CustomerManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (거래처 목록)
const customerColumns: EDataTableColumn[] = [
...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []),
...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "거래유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (거래처 목록) — ts.visibleColumns 순서를 따름
const customerColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
customer_code: { width: "w-[120px]" },
customer_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Truck, Package,
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -42,41 +42,30 @@ const formatNumber = (val: string) => {
};
const parseNumber = (val: string) => val.replace(/,/g, "");
// 마스터 헤더 레이아웃 (수주번호 뒤, 디테일 11컬럼 위에 colSpan으로 맵핑)
// 순서: 거래처 | 단가방식 | 납품처 | 납품장소 | 수주일 | 담당자 → 합계 colSpan = 11
const MASTER_BODY_LAYOUT = [
{ key: "partner_id", label: "거래처", colSpan: 2 },
{ key: "price_mode", label: "단가방식", colSpan: 1 },
{ key: "delivery_partner_id", label: "납품처", colSpan: 2 },
{ key: "delivery_address", label: "납품장소", colSpan: 2 },
{ key: "order_date", label: "수주일", colSpan: 2 },
{ key: "manager_id", label: "담당자", colSpan: 2 },
// 플랫 테이블 컬럼 정의 (마스터+디테일 통합)
const FLAT_COLUMNS = [
{ key: "order_no", label: "수주번호", source: "master" },
{ key: "partner_id", label: "거래처", source: "master" },
{ key: "order_date", label: "수주일", source: "master" },
{ key: "part_code", label: "품번", source: "detail" },
{ key: "part_name", label: "품명", source: "detail" },
{ key: "spec", label: "규격", source: "detail" },
{ key: "unit", label: "단위", source: "detail" },
{ key: "qty", label: "수량", source: "detail" },
{ key: "ship_qty", label: "출하수량", source: "detail" },
{ key: "balance_qty", label: "잔량", source: "detail" },
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "memo", label: "메모", source: "master" },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "part_code", label: "품번" },
{ key: "part_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "qty", label: "수량" },
{ key: "ship_qty", label: "출하수량" },
{ key: "balance_qty", label: "잔량" },
{ key: "unit_price", label: "단가" },
{ key: "amount", label: "금액" },
{ key: "currency_code", label: "통화" },
{ key: "due_date", label: "납기일" },
];
const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = [
{ key: "order_no", label: "수주번호" },
...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })),
...DETAIL_HEADER_COLS,
{ key: "memo", label: "메모" },
];
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 수주번호(1) + 디테일(11) + 메모(1) = 15
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 헤더 필터 Popover
@@ -180,8 +169,6 @@ export default function SalesOrderPage() {
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
@@ -376,25 +363,8 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
orders.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders]);
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["order_no", ...MASTER_BODY_LAYOUT.map((c) => c.key), "memo"]);
// 카테고리 코드→라벨 변환 (마스터 필터용)
const resolveMasterLabel = useCallback((key: string, code: string) => {
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
if (key === "partner_id" || key === "manager_id" || key === "price_mode") {
return categoryOptions[key]?.find((o) => o.code === code)?.label || code;
@@ -402,106 +372,60 @@ export default function SalesOrderPage() {
return code;
}, [categoryOptions]);
// 필터 + 정렬 적용된 데이터 → 그룹핑
const filteredOrderGroups = useMemo(() => {
// 1차: order_no 기준 그룹핑 (필터 전)
const allGroups: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.order_no || "_no_order";
if (!allGroups[key]) {
allGroups[key] = { master: row._master || {}, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위 필터링)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = group.master?.[colKey] ?? "";
const label = resolveMasterLabel(colKey, String(raw));
return values.has(label) || values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위 필터링)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([orderNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
})
);
return [orderNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
// 마스터 필드 정렬 → 그룹 단위
entries.sort(([, a], [, b]) => {
const av = a.master?.[key] ?? "";
const bv = b.master?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
// 디테일 필드 정렬 → 각 그룹 내 디테일 정렬
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [orders, headerFilters, sortState, resolveMasterLabel]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
// 필터 전 전체 마스터에서 고유값 추출
const seenMasters = new Map<string, any>();
orders.forEach((row) => {
if (row.order_no && row._master && !seenMasters.has(row.order_no)) {
seenMasters.set(row.order_no, row._master);
}
// 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합)
const flatRows = useMemo(() => {
return orders.map((row) => {
const master = row._master || {};
return {
...row,
partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""),
order_date: master.order_date || row.order_date || "",
memo: row.memo || master.memo || "",
};
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "order_no", label: "수주번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })), { key: "memo", label: "메모" }]) {
}, [orders, resolveLabel]);
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of FLAT_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = m?.[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(resolveMasterLabel(col.key, String(val)));
}
flatRows.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders, resolveMasterLabel]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredFlatRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -965,111 +889,70 @@ export default function SalesOrderPage() {
</div>
</div>
{/* 데이터 테이블 (트리 구조) */}
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} /> {/* 체크박스 */}
<col style={{ width: "36px" }} /> {/* 펼침 화살표 */}
<col style={{ width: "150px" }} /> {/* 수주번호 */}
<col style={{ width: "120px" }} /> {/* 품번 / 거래처 */}
<col style={{ width: "140px" }} /> {/* 품명 / 거래처(cont) */}
<col style={{ width: "80px" }} /> {/* 규격 / 단가방식 */}
<col style={{ width: "70px" }} /> {/* 단위 / 납품처 */}
<col style={{ width: "80px" }} /> {/* 수량 / 납품처(cont) */}
<col style={{ width: "80px" }} /> {/* 출하수량 / 납품장소 */}
<col style={{ width: "80px" }} /> {/* 잔량 / 납품장소(cont) */}
<col style={{ width: "90px" }} /> {/* 단가 / 수주일 */}
<col style={{ width: "110px" }} /> {/* 금액 / 수주일(cont) */}
<col style={{ width: "60px" }} /> {/* 통화 / 담당자 */}
<col style={{ width: "100px" }} /> {/* 납기일 / 담당자(cont) */}
<col style={{ width: "120px" }} /> {/* 메모 */}
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 수주번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("order_no")}>
<span className="truncate"></span>
{sortState?.key === "order_no" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["order_no"] || []).length > 0 && (
<HeaderFilterPopover
colKey="order_no" colLabel="수주번호"
uniqueValues={masterUniqueValues["order_no"] || []}
filterValues={headerFilters["order_no"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{FLAT_COLUMNS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
{/* 메모 (마스터) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("memo")}>
<span className="truncate"></span>
{sortState?.key === "memo" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["memo"] || []).length > 0 && (
<HeaderFilterPopover
colKey="memo" colLabel="메모"
uniqueValues={masterUniqueValues["memo"] || []}
filterValues={headerFilters["memo"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1079,7 +962,7 @@ export default function SalesOrderPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredOrderGroups).length === 0 ? (
) : filteredFlatRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1089,200 +972,48 @@ export default function SalesOrderPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredOrderGroups).map(([orderNo, group]) => {
const isExpanded = expandedOrders.has(orderNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredFlatRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={orderNo}>
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(orderNo)) {
setClosingOrders((prev) => new Set(prev).add(orderNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(orderNo));
}
}}
onDoubleClick={() => openEditModal(orderNo)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 수주번호 */}
<TableCell className="font-mono whitespace-nowrap">
{orderNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 거래처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""}
</span>
</TableCell>
{/* 단가방식 (colSpan=1) */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""}
</span>
</TableCell>
{/* 납품처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_partner_id || ""}</span>
</TableCell>
{/* 납품장소 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_address || ""}</span>
</TableCell>
{/* 수주일 (colSpan=2) */}
<TableCell colSpan={2} className="whitespace-nowrap text-[13px]">
{master.order_date || ""}
</TableCell>
{/* 담당자 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.manager_id ? (categoryOptions["manager_id"]?.find((o) => o.code === master.manager_id)?.label || master.manager_id) : ""}
</span>
</TableCell>
{/* 메모 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(orderNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
{DETAIL_HEADER_COLS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => d[col.key]).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
<TableCell />
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(orderNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.currency_code || ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell />
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.order_no}</TableCell>
<TableCell className="text-[13px] truncate max-w-[140px]"><span className="block truncate">{row.partner_id || ""}</span></TableCell>
<TableCell className="whitespace-nowrap text-[13px]">{row.order_date || ""}</TableCell>
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -402,32 +402,41 @@ export default function SalesItemPage() {
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
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);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/customer_item_mapping/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,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.customer_item_code || m.customer_item_name)
.map((m: any) => ({ _id: `m_existing_${m.id}`, customer_item_code: m.customer_item_code || "", customer_item_name: m.customer_item_name || "" }));
} catch { /* skip */ }
let priceRows: any[] = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/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,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
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({ _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "" });
}
setSelectedCustsForDetail([custInfo]);
@@ -782,23 +791,17 @@ export default function SalesItemPage() {
"cursor-pointer h-[41px]",
customerCheckedIds.includes(row.id) ? "bg-primary/[0.08]" : "hover:bg-accent"
)}
onClick={() => {
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditCust(row)}
>
<TableCell
className="text-center px-2"
onClick={(e) => {
e.stopPropagation();
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center px-2">
<Checkbox
checked={customerCheckedIds.includes(row.id)}
onCheckedChange={(checked) => {
if (checked === true) setCustomerCheckedIds((prev) => [...prev, row.id]);
else setCustomerCheckedIds((prev) => prev.filter((id) => id !== row.id));
}}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-[13px] font-mono text-muted-foreground">{row.customer_code}</TableCell>
@@ -363,7 +363,7 @@ export default function ShippingOrderPage() {
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
planQty: item.orderQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
@@ -142,15 +142,20 @@ export default function EquipmentInfoPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -272,8 +277,8 @@ export default function EquipmentInfoPage() {
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (isNumeric && !inspectionForm.unit) { toast.error("측정단위가 필수입니다."); return; }
// 기준값/오차범위 → 하한치/상한치 자동 계산
const saveData = { ...inspectionForm };
if (isNumeric && saveData.standard_value) {
@@ -739,7 +744,7 @@ export default function EquipmentInfoPage() {
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
const label = resolve("inspection_method", v);
const isNum = label === "숫자" || v === "숫자";
const isNum = ["숫자", "치수검사"].includes(label) || ["숫자", "치수검사"].includes(v);
if (!isNum) {
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
} else {
@@ -748,7 +753,7 @@ export default function EquipmentInfoPage() {
}, "점검방법")}</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
@@ -758,7 +763,7 @@ export default function EquipmentInfoPage() {
</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="grid grid-cols-2 gap-4">
@@ -333,69 +333,90 @@ export default function MaterialStatusPage() {
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
{ts.isVisible("status") && (
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</>
)}
</div>
</div>
</div>
</div>
))
);
})
)}
</div>
</div>
@@ -140,8 +140,16 @@ const DETAIL_HEADER_COLS = [
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10
const TOTAL_COLS = 10;
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -248,6 +256,31 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -900,8 +933,15 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "100px" }} /><col style={{ width: "120px" }} /><col style={{ width: "120px" }} /><col style={{ width: "100px" }} /><col style={{ width: "90px" }} /><col style={{ width: "120px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -942,8 +982,8 @@ export default function OutboundPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -1039,38 +1079,51 @@ export default function OutboundPage() {
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 출고유형 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
{/* 출고일 */}
<TableCell className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 거래처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 출고상태 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1084,7 +1137,7 @@ export default function OutboundPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1163,20 +1216,18 @@ export default function OutboundPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_code || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>
{/* 출고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -460,18 +460,20 @@ export default function PackagingPage() {
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<EDataTable
columns={[
{ key: "pkg_code", label: "품목코드" },
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형", width: "w-[80px]", render: (v) => PKG_TYPE_LABEL[v] || v || "-" },
{ key: "size", label: "크기(mm)", width: "w-[100px]", render: (_v, row) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
{ key: "max_load_kg", label: "최대중량", width: "w-[80px]", align: "right", render: (v) => Number(v || 0) > 0 ? `${v}kg` : "-" },
{ key: "status", label: "상태", width: "w-[60px]", align: "center", render: (v) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
] as EDataTableColumn<PkgUnit>[]}
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
pkg_type: { width: "w-[80px]", render: (v: any) => PKG_TYPE_LABEL[v] || v || "-" },
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
loading={pkgLoading}
@@ -117,12 +117,20 @@ const DETAIL_HEADER_COLS = [
{ key: "total_amount", label: "금액" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10
const TOTAL_COLS = 10;
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
@@ -278,6 +286,31 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -847,8 +880,15 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /><col style={{ width: "160px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -889,8 +929,8 @@ export default function ReceivingPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -985,38 +1025,51 @@ export default function ReceivingPage() {
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 입고유형 */}
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
{/* 입고일 */}
<TableCell className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 공급처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 입고상태 */}
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1030,7 +1083,7 @@ export default function ReceivingPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1108,20 +1161,18 @@ export default function ReceivingPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
{/* 입고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -491,12 +491,6 @@ export default function CompanyPage() {
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger
value="department"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Users className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
@@ -635,89 +629,6 @@ export default function CompanyPage() {
</div>
</TabsContent>
{/* ===================== Tab 2: 부서관리 ===================== */}
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
<div className="h-full overflow-hidden border rounded-none bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 트리 */}
<ResizablePanel defaultSize={30} minSize={20}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span></span>
<Badge variant="secondary" className="font-mono text-xs">{depts.length}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" className="h-8" onClick={openDeptRegister}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{deptLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : deptTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Building2 className="w-8 h-8 mb-2" />
<span className="text-sm"> </span>
</div>
) : (
renderTree(deptTree)
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={70} minSize={40}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}</Badge>}
</div>
{selectedDeptCode && (
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{selectedDeptCode ? (
<EDataTable
columns={companyMemberColumns}
data={members}
rowKey={(row) => row.user_id || row.id}
loading={memberLoading}
emptyMessage="소속 사원이 없어요"
emptyIcon={<Users className="w-8 h-8 mb-2" />}
onRowDoubleClick={(row) => openUserModal(row)}
showPagination={false}
draggableColumns={false}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Users className="w-10 h-10 mb-3" />
<span className="text-sm"> </span>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
@@ -9,7 +9,7 @@
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -279,6 +279,7 @@ export default function DepartmentPage() {
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
end_date: userForm.end_date || null,
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
@@ -308,41 +309,45 @@ export default function DepartmentPage() {
};
// 퇴사일 기반 재직/퇴사 분리
const today = new Date().toISOString().split("T")[0];
const _now = new Date();
const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
const isColVisible = (key: string) => ts.isVisible(key);
// EDataTable 컬럼 정의 (부서 목록)
const deptColumns: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
...(isColVisible("parent_dept_code")
? [{
key: "parent_dept_code",
label: "상위부서",
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
}]
: []),
...(isColVisible("status")
? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
}]
: []),
];
// EDataTable 컬럼 정의 (부서 목록) — ts.visibleColumns 순서를 따름
const deptColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
dept_code: { width: "w-[120px]" },
dept_name: { minWidth: "min-w-[140px]" },
parent_dept_code: {
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
},
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
},
};
// dept_code, dept_name은 항상 표시 (DEPT_COLUMNS에 포함되지 않으므로 visibleColumns에 없음)
const fixedCols: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", ...colProps["dept_code"] },
{ key: "dept_name", label: "부서명", ...colProps["dept_name"] },
];
const dynamicCols = ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
return [...fixedCols, ...dynamicCols];
}, [ts.visibleColumns]);
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -84,6 +84,56 @@ function CategoryCombobox({ options, value, onChange, placeholder }: {
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
@@ -108,7 +158,7 @@ const GRID_COLUMNS = [
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
@@ -137,6 +187,7 @@ export default function ItemInfoPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 검색 필터 (DynamicSearchFilter)
@@ -197,7 +248,7 @@ export default function ItemInfoPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
size: 99999,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -215,6 +266,7 @@ export default function ItemInfoPage() {
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
@@ -261,7 +313,8 @@ export default function ItemInfoPage() {
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -269,7 +322,8 @@ export default function ItemInfoPage() {
// 복사 모달 열기
const openCopyModal = async (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
@@ -459,6 +513,13 @@ export default function ItemInfoPage() {
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
@@ -115,17 +115,22 @@ export default function SubcontractorItemPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" });
if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" });
if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" });
if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
size: { width: "w-[90px]", render: (v) => v || "-" },
unit: { width: "w-[60px]", render: (v) => v || "-" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
status: { width: "w-[60px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
@@ -139,7 +139,7 @@ export default function ProductionPlanManagementPage() {
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
const [equipmentList, setEquipmentList] = useState<{ equipment_id: string; equipment_name: string }[]>([]);
const [equipmentList, setEquipmentList] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
// 선택/토글 상태
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
@@ -659,7 +659,7 @@ export default function ProductionPlanManagementPage() {
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId(plan.equipment_id ? String(plan.equipment_id) : "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setScheduleModalOpen(true);
}, []);
@@ -674,7 +674,10 @@ export default function ProductionPlanManagementPage() {
manager_name: modalManager,
work_order_no: modalWorkOrderNo,
remarks: modalRemarks,
equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null,
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
: null,
} as any);
if (res.success) {
toast.success("생산계획이 수정되었습니다");
@@ -919,9 +922,7 @@ export default function ProductionPlanManagementPage() {
// 숫자 포맷
const formatNumber = (num: number | string) => Number(num).toLocaleString();
// 컬럼 표시 여부
const isColVisible = (key: string) => ts.isVisible(key);
const orderColSpan = 4 + ORDER_COLUMNS.filter((c) => isColVisible(c.key)).length;
// (컬럼 표시는 ts.visibleColumns 순서를 따름)
return (
<div className={cn("flex flex-col gap-3", isFullscreen ? "fixed inset-0 z-50 bg-background p-4" : "h-full p-3")}>
@@ -1019,6 +1020,38 @@ export default function ProductionPlanManagementPage() {
</div>
) : (
<div className="overflow-x-auto rounded-md border">
{(() => {
// 디테일 행에서 개별 값을 표시하는 컬럼 매핑
const DETAIL_VALUE_MAP: Record<string, string> = {
total_order_qty: "order_qty",
total_ship_qty: "ship_qty",
total_balance_qty: "balance_qty",
};
// 그룹 행에서 특수 렌더링이 필요한 컬럼
const renderGroupCell = (col: { key: string }, item: any) => {
if (col.key === "required_plan_qty") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
);
}
if (col.key === "lead_time") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
);
}
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item[col.key])}
</TableCell>
);
};
return (
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -1028,15 +1061,11 @@ export default function ProductionPlanManagementPage() {
<TableHead className="w-[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>
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("current_stock") && <TableHead style={ts.thStyle("current_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("safety_stock") && <TableHead style={ts.thStyle("safety_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("existing_plan_qty") && <TableHead style={ts.thStyle("existing_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("in_progress_qty") && <TableHead style={ts.thStyle("in_progress_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("required_plan_qty") && <TableHead style={ts.thStyle("required_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("lead_time") && <TableHead style={ts.thStyle("lead_time")} className="text-xs font-bold whitespace-nowrap text-right 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">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
@@ -1046,6 +1075,7 @@ export default function ProductionPlanManagementPage() {
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1068,25 +1098,14 @@ export default function ProductionPlanManagementPage() {
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}
{isColVisible("current_stock") && <TableCell style={ts.thStyle("current_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>}
{isColVisible("safety_stock") && <TableCell style={ts.thStyle("safety_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>}
{isColVisible("existing_plan_qty") && <TableCell style={ts.thStyle("existing_plan_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>}
{isColVisible("in_progress_qty") && <TableCell style={ts.thStyle("in_progress_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>}
{isColVisible("required_plan_qty") && (
<TableCell style={ts.thStyle("required_plan_qty")} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
)}
{isColVisible("lead_time") && (
<TableCell style={ts.thStyle("lead_time")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
)}
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
let remainColSpan = 0;
for (const col of ts.visibleColumns) {
if (!DETAIL_VALUE_MAP[col.key]) remainColSpan++;
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
@@ -1101,19 +1120,28 @@ export default function ProductionPlanManagementPage() {
</Badge>
</div>
</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right">{formatNumber(detail.order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right">{formatNumber(detail.ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right">{formatNumber(detail.balance_qty)}</TableCell>}
<TableCell colSpan={orderColSpan - 2 - (isColVisible("total_order_qty") ? 1 : 0) - (isColVisible("total_ship_qty") ? 1 : 0) - (isColVisible("total_balance_qty") ? 1 : 0)} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
{ts.visibleColumns.map((col) => {
const detailKey = DETAIL_VALUE_MAP[col.key];
if (detailKey) {
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right">{formatNumber(detail[detailKey])}</TableCell>;
}
return null;
})}
{remainColSpan > 0 && (
<TableCell colSpan={remainColSpan} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
)}
</TableRow>
))}
);
})}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
})()}
</div>
)}
</div>
@@ -1401,8 +1429,8 @@ export default function ProductionPlanManagementPage() {
<SelectContent>
<SelectItem value="none"></SelectItem>
{equipmentList.map((eq) => (
<SelectItem key={eq.equipment_id} value={String(eq.equipment_id)}>
{eq.equipment_name} ({eq.equipment_id})
<SelectItem key={eq.id} value={eq.equipment_code || eq.id}>
{eq.equipment_name} ({eq.equipment_code})
</SelectItem>
))}
</SelectContent>
@@ -742,10 +742,24 @@ export default function PurchaseOrderPage() {
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const detailCols = ts.visibleColumns.filter(c => !MASTER_KEYS.has(c.key));
const masterCols = ts.visibleColumns.filter(c => MASTER_KEYS.has(c.key));
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
@@ -753,23 +767,35 @@ export default function PurchaseOrderPage() {
return val || "-";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{ts.isVisible("purchase_no") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("order_date") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("supplier_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
</TableHead>
))}
{ts.isVisible("status") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>}
{ts.isVisible("memo") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
@@ -795,9 +821,7 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>}
{ts.isVisible("order_date") && <TableCell className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>}
{ts.isVisible("supplier_name") && <TableCell className="text-sm">{m.supplier_name || "-"}</TableCell>}
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
@@ -806,8 +830,7 @@ export default function PurchaseOrderPage() {
: ""}
</TableCell>
))}
{ts.isVisible("status") && <TableCell className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>}
{ts.isVisible("memo") && <TableCell className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
@@ -815,17 +838,14 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell />}
{ts.isVisible("order_date") && <TableCell />}
{ts.isVisible("supplier_name") && <TableCell />}
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{ts.isVisible("status") && <TableCell />}
{ts.isVisible("memo") && <TableCell />}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
@@ -617,17 +617,21 @@ export default function PurchaseItemPage() {
toast.success("다운로드 완료");
};
// EDataTable 컬럼 정의 (구매품목)
const itemColumns: EDataTableColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "standard_price", label: "구매단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// EDataTable 컬럼 정의 (구매품목) — ts.visibleColumns 기반
const COLUMN_RENDER_MAP: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]" },
size: { width: "w-[80px]" },
unit: { width: "w-[60px]" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]" },
status: { width: "w-[60px]" },
};
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
...COLUMN_RENDER_MAP[col.key],
}));
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -1229,47 +1229,44 @@ export default function SupplierManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const supplierColSpan = 1 + ["supplier_code", "supplier_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (공급업체 목록)
const supplierColumns: EDataTableColumn[] = [
...(isColumnVisible("supplier_code") ? [{ key: "supplier_code", label: "공급업체코드", width: "w-[120px]" }] : []),
...(isColumnVisible("supplier_name") ? [{ key: "supplier_name", label: "공급업체명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "공급업체유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (공급업체 목록) — ts.visibleColumns 순서를 따름
const supplierColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
supplier_code: { width: "w-[120px]" },
supplier_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -28,6 +28,7 @@ const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
@@ -420,18 +421,41 @@ export default function ItemInspectionInfoPage() {
<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>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{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) => {
{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 => r.id);
const allChecked = groupIds.every(id => checkedIds.includes(id));
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
@@ -445,21 +469,9 @@ export default function ItemInspectionInfoPage() {
<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>
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
<TableCell className="text-sm">{group.item_name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
<TableCell>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
{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 />
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -345,7 +345,8 @@ export default function CustomerManagementPage() {
if (!code) return "";
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const today = new Date().toISOString().split("T")[0];
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
// 품목 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
const grouped: Record<string, { master: any; details: any[] }> = {};
@@ -810,22 +811,26 @@ export default function CustomerManagementPage() {
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const SALES_CODES = ["CAT_ML8ZFVEL_1TOR"]; // 영업관리 카테고리 코드
setItemSearchResults(allItems.filter((item: any) => {
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => SALES_CODES.includes(code));
}));
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1229,47 +1234,44 @@ export default function CustomerManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (거래처 목록)
const customerColumns: EDataTableColumn[] = [
...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []),
...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "거래유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (거래처 목록) — ts.visibleColumns 순서를 따름
const customerColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
customer_code: { width: "w-[120px]" },
customer_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Truck, Package,
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -42,41 +42,30 @@ const formatNumber = (val: string) => {
};
const parseNumber = (val: string) => val.replace(/,/g, "");
// 마스터 헤더 레이아웃 (수주번호 뒤, 디테일 11컬럼 위에 colSpan으로 맵핑)
// 순서: 거래처 | 단가방식 | 납품처 | 납품장소 | 수주일 | 담당자 → 합계 colSpan = 11
const MASTER_BODY_LAYOUT = [
{ key: "partner_id", label: "거래처", colSpan: 2 },
{ key: "price_mode", label: "단가방식", colSpan: 1 },
{ key: "delivery_partner_id", label: "납품처", colSpan: 2 },
{ key: "delivery_address", label: "납품장소", colSpan: 2 },
{ key: "order_date", label: "수주일", colSpan: 2 },
{ key: "manager_id", label: "담당자", colSpan: 2 },
// 플랫 테이블 컬럼 정의 (마스터+디테일 통합)
const FLAT_COLUMNS = [
{ key: "order_no", label: "수주번호", source: "master" },
{ key: "partner_id", label: "거래처", source: "master" },
{ key: "order_date", label: "수주일", source: "master" },
{ key: "part_code", label: "품번", source: "detail" },
{ key: "part_name", label: "품명", source: "detail" },
{ key: "spec", label: "규격", source: "detail" },
{ key: "unit", label: "단위", source: "detail" },
{ key: "qty", label: "수량", source: "detail" },
{ key: "ship_qty", label: "출하수량", source: "detail" },
{ key: "balance_qty", label: "잔량", source: "detail" },
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "memo", label: "메모", source: "master" },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "part_code", label: "품번" },
{ key: "part_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "qty", label: "수량" },
{ key: "ship_qty", label: "출하수량" },
{ key: "balance_qty", label: "잔량" },
{ key: "unit_price", label: "단가" },
{ key: "amount", label: "금액" },
{ key: "currency_code", label: "통화" },
{ key: "due_date", label: "납기일" },
];
const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = [
{ key: "order_no", label: "수주번호" },
...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })),
...DETAIL_HEADER_COLS,
{ key: "memo", label: "메모" },
];
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 수주번호(1) + 디테일(11) + 메모(1) = 15
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 헤더 필터 Popover
@@ -180,8 +169,6 @@ export default function SalesOrderPage() {
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
@@ -376,25 +363,8 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
orders.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders]);
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["order_no", ...MASTER_BODY_LAYOUT.map((c) => c.key), "memo"]);
// 카테고리 코드→라벨 변환 (마스터 필터용)
const resolveMasterLabel = useCallback((key: string, code: string) => {
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
if (key === "partner_id" || key === "manager_id" || key === "price_mode") {
return categoryOptions[key]?.find((o) => o.code === code)?.label || code;
@@ -402,106 +372,60 @@ export default function SalesOrderPage() {
return code;
}, [categoryOptions]);
// 필터 + 정렬 적용된 데이터 → 그룹핑
const filteredOrderGroups = useMemo(() => {
// 1차: order_no 기준 그룹핑 (필터 전)
const allGroups: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.order_no || "_no_order";
if (!allGroups[key]) {
allGroups[key] = { master: row._master || {}, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위 필터링)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = group.master?.[colKey] ?? "";
const label = resolveMasterLabel(colKey, String(raw));
return values.has(label) || values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위 필터링)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([orderNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
})
);
return [orderNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
// 마스터 필드 정렬 → 그룹 단위
entries.sort(([, a], [, b]) => {
const av = a.master?.[key] ?? "";
const bv = b.master?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
// 디테일 필드 정렬 → 각 그룹 내 디테일 정렬
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [orders, headerFilters, sortState, resolveMasterLabel]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
// 필터 전 전체 마스터에서 고유값 추출
const seenMasters = new Map<string, any>();
orders.forEach((row) => {
if (row.order_no && row._master && !seenMasters.has(row.order_no)) {
seenMasters.set(row.order_no, row._master);
}
// 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합)
const flatRows = useMemo(() => {
return orders.map((row) => {
const master = row._master || {};
return {
...row,
partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""),
order_date: master.order_date || row.order_date || "",
memo: row.memo || master.memo || "",
};
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "order_no", label: "수주번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })), { key: "memo", label: "메모" }]) {
}, [orders, resolveLabel]);
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of FLAT_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = m?.[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(resolveMasterLabel(col.key, String(val)));
}
flatRows.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders, resolveMasterLabel]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredFlatRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -965,111 +889,70 @@ export default function SalesOrderPage() {
</div>
</div>
{/* 데이터 테이블 (트리 구조) */}
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} /> {/* 체크박스 */}
<col style={{ width: "36px" }} /> {/* 펼침 화살표 */}
<col style={{ width: "150px" }} /> {/* 수주번호 */}
<col style={{ width: "120px" }} /> {/* 품번 / 거래처 */}
<col style={{ width: "140px" }} /> {/* 품명 / 거래처(cont) */}
<col style={{ width: "80px" }} /> {/* 규격 / 단가방식 */}
<col style={{ width: "70px" }} /> {/* 단위 / 납품처 */}
<col style={{ width: "80px" }} /> {/* 수량 / 납품처(cont) */}
<col style={{ width: "80px" }} /> {/* 출하수량 / 납품장소 */}
<col style={{ width: "80px" }} /> {/* 잔량 / 납품장소(cont) */}
<col style={{ width: "90px" }} /> {/* 단가 / 수주일 */}
<col style={{ width: "110px" }} /> {/* 금액 / 수주일(cont) */}
<col style={{ width: "60px" }} /> {/* 통화 / 담당자 */}
<col style={{ width: "100px" }} /> {/* 납기일 / 담당자(cont) */}
<col style={{ width: "120px" }} /> {/* 메모 */}
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 수주번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("order_no")}>
<span className="truncate"></span>
{sortState?.key === "order_no" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["order_no"] || []).length > 0 && (
<HeaderFilterPopover
colKey="order_no" colLabel="수주번호"
uniqueValues={masterUniqueValues["order_no"] || []}
filterValues={headerFilters["order_no"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{FLAT_COLUMNS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
{/* 메모 (마스터) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("memo")}>
<span className="truncate"></span>
{sortState?.key === "memo" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["memo"] || []).length > 0 && (
<HeaderFilterPopover
colKey="memo" colLabel="메모"
uniqueValues={masterUniqueValues["memo"] || []}
filterValues={headerFilters["memo"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1079,7 +962,7 @@ export default function SalesOrderPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredOrderGroups).length === 0 ? (
) : filteredFlatRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1089,200 +972,48 @@ export default function SalesOrderPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredOrderGroups).map(([orderNo, group]) => {
const isExpanded = expandedOrders.has(orderNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredFlatRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={orderNo}>
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(orderNo)) {
setClosingOrders((prev) => new Set(prev).add(orderNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(orderNo));
}
}}
onDoubleClick={() => openEditModal(orderNo)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 수주번호 */}
<TableCell className="font-mono whitespace-nowrap">
{orderNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 거래처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""}
</span>
</TableCell>
{/* 단가방식 (colSpan=1) */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""}
</span>
</TableCell>
{/* 납품처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_partner_id || ""}</span>
</TableCell>
{/* 납품장소 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_address || ""}</span>
</TableCell>
{/* 수주일 (colSpan=2) */}
<TableCell colSpan={2} className="whitespace-nowrap text-[13px]">
{master.order_date || ""}
</TableCell>
{/* 담당자 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.manager_id ? (categoryOptions["manager_id"]?.find((o) => o.code === master.manager_id)?.label || master.manager_id) : ""}
</span>
</TableCell>
{/* 메모 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(orderNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
{DETAIL_HEADER_COLS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => d[col.key]).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
<TableCell />
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(orderNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.currency_code || ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell />
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.order_no}</TableCell>
<TableCell className="text-[13px] truncate max-w-[140px]"><span className="block truncate">{row.partner_id || ""}</span></TableCell>
<TableCell className="whitespace-nowrap text-[13px]">{row.order_date || ""}</TableCell>
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -402,25 +402,51 @@ export default function SalesItemPage() {
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
// 매핑 조회
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/customer_item_mapping/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,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.customer_item_code || m.customer_item_name)
.map((m: any) => ({
_id: `m_existing_${m.id}`,
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_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/customer_item_prices/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,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
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({
@@ -782,23 +808,17 @@ export default function SalesItemPage() {
"cursor-pointer h-[41px]",
customerCheckedIds.includes(row.id) ? "bg-primary/[0.08]" : "hover:bg-accent"
)}
onClick={() => {
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditCust(row)}
>
<TableCell
className="text-center px-2"
onClick={(e) => {
e.stopPropagation();
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center px-2">
<Checkbox
checked={customerCheckedIds.includes(row.id)}
onCheckedChange={(checked) => {
if (checked === true) setCustomerCheckedIds((prev) => [...prev, row.id]);
else setCustomerCheckedIds((prev) => prev.filter((id) => id !== row.id));
}}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-[13px] font-mono text-muted-foreground">{row.customer_code}</TableCell>
@@ -363,7 +363,7 @@ export default function ShippingOrderPage() {
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
planQty: item.orderQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
@@ -142,15 +142,20 @@ export default function EquipmentInfoPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
@@ -272,8 +277,8 @@ export default function EquipmentInfoPage() {
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (isNumeric && !inspectionForm.unit) { toast.error("측정단위가 필수입니다."); return; }
// 기준값/오차범위 → 하한치/상한치 자동 계산
const saveData = { ...inspectionForm };
if (isNumeric && saveData.standard_value) {
@@ -739,7 +744,7 @@ export default function EquipmentInfoPage() {
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
const label = resolve("inspection_method", v);
const isNum = label === "숫자" || v === "숫자";
const isNum = ["숫자", "치수검사"].includes(label) || ["숫자", "치수검사"].includes(v);
if (!isNum) {
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
} else {
@@ -748,7 +753,7 @@ export default function EquipmentInfoPage() {
}, "점검방법")}</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
@@ -758,7 +763,7 @@ export default function EquipmentInfoPage() {
</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="grid grid-cols-2 gap-4">
@@ -333,69 +333,90 @@ export default function MaterialStatusPage() {
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
{ts.isVisible("status") && (
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</>
)}
</div>
</div>
</div>
</div>
))
);
})
)}
</div>
</div>
@@ -140,8 +140,16 @@ const DETAIL_HEADER_COLS = [
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10
const TOTAL_COLS = 10;
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_type",
item_number: "item_code",
item_name: "item_name",
spec: "specification",
outbound_qty: "outbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -248,6 +256,31 @@ interface SelectedSourceItem {
export default function OutboundPage() {
const ts = useTableSettings("c16-outbound", "outbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<OutboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -900,8 +933,15 @@ export default function OutboundPage() {
</div>
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "100px" }} /><col style={{ width: "120px" }} /><col style={{ width: "120px" }} /><col style={{ width: "100px" }} /><col style={{ width: "90px" }} /><col style={{ width: "120px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -942,8 +982,8 @@ export default function OutboundPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -1039,38 +1079,51 @@ export default function OutboundPage() {
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 출고유형 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
{/* 출고일 */}
<TableCell className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 거래처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 출고상태 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "outbound_type": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
);
case "outbound_date": return (
<TableCell key={col.key} className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "customer_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "outbound_status": return (
<TableCell key={col.key}>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1084,7 +1137,7 @@ export default function OutboundPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1163,20 +1216,18 @@ export default function OutboundPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_code || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>
{/* 출고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_type": return <TableCell key={col.key} className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>;
case "item_code": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_code || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "specification": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>;
case "outbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -460,18 +460,20 @@ export default function PackagingPage() {
{/* 포장재 목록 테이블 */}
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
<EDataTable
columns={[
{ key: "pkg_code", label: "품목코드" },
{ key: "pkg_name", label: "포장명" },
{ key: "pkg_type", label: "유형", width: "w-[80px]", render: (v) => PKG_TYPE_LABEL[v] || v || "-" },
{ key: "size", label: "크기(mm)", width: "w-[100px]", render: (_v, row) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
{ key: "max_load_kg", label: "최대중량", width: "w-[80px]", align: "right", render: (v) => Number(v || 0) > 0 ? `${v}kg` : "-" },
{ key: "status", label: "상태", width: "w-[60px]", align: "center", render: (v) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
] as EDataTableColumn<PkgUnit>[]}
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
pkg_type: { width: "w-[80px]", render: (v: any) => PKG_TYPE_LABEL[v] || v || "-" },
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
status: { width: "w-[60px]", align: "center", render: (v: any) => (
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
{STATUS_LABEL[v] || v}
</span>
)},
};
return { key: col.key, label: col.label, ...renderMap[col.key] };
})}
data={ts.groupData(filteredPkgUnits)}
rowKey={(row) => String(row.id)}
loading={pkgLoading}
@@ -117,12 +117,20 @@ const DETAIL_HEADER_COLS = [
{ key: "total_amount", label: "금액" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10
const TOTAL_COLS = 10;
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 디테일 필드 키 매핑 (GRID_COLUMNS key → DETAIL_HEADER_COLS key)
const DETAIL_KEY_MAP: Record<string, string> = {
source_type: "source_table",
item_number: "item_number",
item_name: "item_name",
spec: "spec",
inbound_qty: "inbound_qty",
unit_price: "unit_price",
total_amount: "total_amount",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
@@ -278,6 +286,31 @@ interface SelectedSourceItem {
export default function ReceivingPage() {
const ts = useTableSettings("c16-receiving", "inbound_mng", GRID_COLUMNS);
// ts.visibleColumns 기반 마스터/디테일 컬럼 계산
const visibleMasterLayout = useMemo(() => {
const ordered: typeof MASTER_BODY_LAYOUT = [];
for (const vc of ts.visibleColumns) {
const m = MASTER_BODY_LAYOUT.find((ml) => ml.key === vc.key);
if (m) ordered.push(m);
}
return ordered.length > 0 ? ordered : MASTER_BODY_LAYOUT;
}, [ts.visibleColumns]);
const visibleDetailCols = useMemo(() => {
const ordered: typeof DETAIL_HEADER_COLS = [];
for (const vc of ts.visibleColumns) {
const detailKey = DETAIL_KEY_MAP[vc.key];
if (detailKey) {
const d = DETAIL_HEADER_COLS.find((dc) => dc.key === detailKey);
if (d) ordered.push(d);
}
}
return ordered.length > 0 ? ordered : DETAIL_HEADER_COLS;
}, [ts.visibleColumns]);
const TOTAL_COLS = 3 + visibleMasterLayout.length;
// 목록 데이터
const [data, setData] = useState<InboundItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -847,8 +880,15 @@ export default function ReceivingPage() {
</div>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /><col style={{ width: "160px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /></colgroup>
<Table style={{ minWidth: `${216 + visibleMasterLayout.length * 120}px` }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "140px" }} />
{visibleMasterLayout.map((col) => (
<col key={col.key} style={{ width: ts.getWidth(col.key) ? `${ts.getWidth(col.key)}px` : undefined }} />
))}
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
@@ -889,8 +929,8 @@ export default function ReceivingPage() {
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
{/* 마스터 필드 헤더 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
@@ -985,38 +1025,51 @@ export default function ReceivingPage() {
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 입고유형 */}
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
{/* 입고일 */}
<TableCell className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 공급처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 입고상태 */}
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
{/* 마스터 필드 (ts.visibleColumns 순서) */}
{visibleMasterLayout.map((col) => {
switch (col.key) {
case "inbound_type": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
);
case "inbound_date": return (
<TableCell key={col.key} className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
);
case "reference_number": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
);
case "supplier_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
);
case "warehouse_name": return (
<TableCell key={col.key} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
);
case "inbound_status": return (
<TableCell key={col.key} className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
);
case "memo": return (
<TableCell key={col.key} className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
);
default: return <TableCell key={col.key} className="text-[13px]">{(master as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
@@ -1030,7 +1083,7 @@ export default function ReceivingPage() {
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
{visibleDetailCols.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
@@ -1108,20 +1161,18 @@ export default function ReceivingPage() {
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
{/* 입고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
{visibleDetailCols.map((col) => {
switch (col.key) {
case "source_table": return <TableCell key={col.key} className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>;
case "item_number": return <TableCell key={col.key} className="font-mono text-[13px]">{row.item_number || ""}</TableCell>;
case "item_name": return <TableCell key={col.key} className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>;
case "spec": return <TableCell key={col.key} className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>;
case "inbound_qty": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>;
case "unit_price": return <TableCell key={col.key} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>;
case "total_amount": return <TableCell key={col.key} className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>;
default: return <TableCell key={col.key} className="text-[13px]">{(row as any)[col.key] ?? ""}</TableCell>;
}
})}
</TableRow>
);
})}
@@ -491,12 +491,6 @@ export default function CompanyPage() {
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger
value="department"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Users className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
@@ -635,89 +629,6 @@ export default function CompanyPage() {
</div>
</TabsContent>
{/* ===================== Tab 2: 부서관리 ===================== */}
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
<div className="h-full overflow-hidden border rounded-none bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 트리 */}
<ResizablePanel defaultSize={30} minSize={20}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span></span>
<Badge variant="secondary" className="font-mono text-xs">{depts.length}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" className="h-8" onClick={openDeptRegister}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{deptLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : deptTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Building2 className="w-8 h-8 mb-2" />
<span className="text-sm"> </span>
</div>
) : (
renderTree(deptTree)
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={70} minSize={40}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}</Badge>}
</div>
{selectedDeptCode && (
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{selectedDeptCode ? (
<EDataTable
columns={companyMemberColumns}
data={members}
rowKey={(row) => row.user_id || row.id}
loading={memberLoading}
emptyMessage="소속 사원이 없어요"
emptyIcon={<Users className="w-8 h-8 mb-2" />}
onRowDoubleClick={(row) => openUserModal(row)}
showPagination={false}
draggableColumns={false}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Users className="w-10 h-10 mb-3" />
<span className="text-sm"> </span>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
@@ -9,7 +9,7 @@
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -279,6 +279,7 @@ export default function DepartmentPage() {
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
end_date: userForm.end_date || null,
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
@@ -308,41 +309,45 @@ export default function DepartmentPage() {
};
// 퇴사일 기반 재직/퇴사 분리
const today = new Date().toISOString().split("T")[0];
const _now = new Date();
const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
const isColVisible = (key: string) => ts.isVisible(key);
// EDataTable 컬럼 정의 (부서 목록)
const deptColumns: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
...(isColVisible("parent_dept_code")
? [{
key: "parent_dept_code",
label: "상위부서",
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
}]
: []),
...(isColVisible("status")
? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
}]
: []),
];
// EDataTable 컬럼 정의 (부서 목록) — ts.visibleColumns 순서를 따름
const deptColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
dept_code: { width: "w-[120px]" },
dept_name: { minWidth: "min-w-[140px]" },
parent_dept_code: {
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
},
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
},
};
// dept_code, dept_name은 항상 표시 (DEPT_COLUMNS에 포함되지 않으므로 visibleColumns에 없음)
const fixedCols: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", ...colProps["dept_code"] },
{ key: "dept_name", label: "부서명", ...colProps["dept_name"] },
];
const dynamicCols = ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
return [...fixedCols, ...dynamicCols];
}, [ts.visibleColumns]);
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -84,6 +84,56 @@ function CategoryCombobox({ options, value, onChange, placeholder }: {
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
@@ -108,7 +158,7 @@ const GRID_COLUMNS = [
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
@@ -137,6 +187,7 @@ export default function ItemInfoPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 검색 필터 (DynamicSearchFilter)
@@ -197,7 +248,7 @@ export default function ItemInfoPage() {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
size: 99999,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
@@ -215,6 +266,7 @@ export default function ItemInfoPage() {
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
@@ -261,7 +313,8 @@ export default function ItemInfoPage() {
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
@@ -269,7 +322,8 @@ export default function ItemInfoPage() {
// 복사 모달 열기
const openCopyModal = async (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
@@ -459,6 +513,13 @@ export default function ItemInfoPage() {
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
@@ -115,17 +115,22 @@ export default function SubcontractorItemPage() {
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const cols: EDataTableColumn[] = [];
if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" });
if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" });
if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true });
if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" });
if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" });
return cols;
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
const colProps: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
size: { width: "w-[90px]", render: (v) => v || "-" },
unit: { width: "w-[60px]", render: (v) => v || "-" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
status: { width: "w-[60px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
@@ -139,7 +139,7 @@ export default function ProductionPlanManagementPage() {
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
const [equipmentList, setEquipmentList] = useState<{ equipment_id: string; equipment_name: string }[]>([]);
const [equipmentList, setEquipmentList] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
// 선택/토글 상태
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
@@ -659,7 +659,7 @@ export default function ProductionPlanManagementPage() {
setModalManager((plan as any).manager_name || "");
setModalWorkOrderNo((plan as any).work_order_no || "");
setModalRemarks(plan.remarks || "");
setModalEquipmentId(plan.equipment_id ? String(plan.equipment_id) : "");
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
setScheduleModalOpen(true);
}, []);
@@ -919,9 +919,7 @@ export default function ProductionPlanManagementPage() {
// 숫자 포맷
const formatNumber = (num: number | string) => Number(num).toLocaleString();
// 컬럼 표시 여부
const isColVisible = (key: string) => ts.isVisible(key);
const orderColSpan = 4 + ORDER_COLUMNS.filter((c) => isColVisible(c.key)).length;
// (컬럼 표시는 ts.visibleColumns 순서를 따름)
return (
<div className={cn("flex flex-col gap-3", isFullscreen ? "fixed inset-0 z-50 bg-background p-4" : "h-full p-3")}>
@@ -1019,6 +1017,38 @@ export default function ProductionPlanManagementPage() {
</div>
) : (
<div className="overflow-x-auto rounded-md border">
{(() => {
// 디테일 행에서 개별 값을 표시하는 컬럼 매핑
const DETAIL_VALUE_MAP: Record<string, string> = {
total_order_qty: "order_qty",
total_ship_qty: "ship_qty",
total_balance_qty: "balance_qty",
};
// 그룹 행에서 특수 렌더링이 필요한 컬럼
const renderGroupCell = (col: { key: string }, item: any) => {
if (col.key === "required_plan_qty") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
);
}
if (col.key === "lead_time") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
);
}
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item[col.key])}
</TableCell>
);
};
return (
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
@@ -1028,15 +1058,11 @@ export default function ProductionPlanManagementPage() {
<TableHead className="w-[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>
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("current_stock") && <TableHead style={ts.thStyle("current_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("safety_stock") && <TableHead style={ts.thStyle("safety_stock")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("existing_plan_qty") && <TableHead style={ts.thStyle("existing_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("in_progress_qty") && <TableHead style={ts.thStyle("in_progress_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("required_plan_qty") && <TableHead style={ts.thStyle("required_plan_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("lead_time") && <TableHead style={ts.thStyle("lead_time")} className="text-xs font-bold whitespace-nowrap text-right 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">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
@@ -1046,6 +1072,7 @@ export default function ProductionPlanManagementPage() {
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell />
<TableCell />
<TableCell colSpan={2} />
{ts.visibleColumns.map((col) => {
const v = (item as any)[col.key];
return (
@@ -1068,25 +1095,14 @@ export default function ProductionPlanManagementPage() {
</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}
{isColVisible("current_stock") && <TableCell style={ts.thStyle("current_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.current_stock)}</TableCell>}
{isColVisible("safety_stock") && <TableCell style={ts.thStyle("safety_stock")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.safety_stock)}</TableCell>}
{isColVisible("existing_plan_qty") && <TableCell style={ts.thStyle("existing_plan_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.existing_plan_qty)}</TableCell>}
{isColVisible("in_progress_qty") && <TableCell style={ts.thStyle("in_progress_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.in_progress_qty)}</TableCell>}
{isColVisible("required_plan_qty") && (
<TableCell style={ts.thStyle("required_plan_qty")} className={cn("text-[13px] text-right font-bold", Number(item.required_plan_qty) > 0 ? "text-destructive" : "text-success")} onClick={() => toggleItemExpand(item.item_code)}>
{formatNumber(item.required_plan_qty)}
</TableCell>
)}
{isColVisible("lead_time") && (
<TableCell style={ts.thStyle("lead_time")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>
{Number(item.lead_time) > 0 ? `${item.lead_time}` : "-"}
</TableCell>
)}
{ts.visibleColumns.map((col) => renderGroupCell(col, item))}
</TableRow>
{expandedItems.has(item.item_code) && item.orders?.map((detail) => (
{expandedItems.has(item.item_code) && item.orders?.map((detail: any) => {
let remainColSpan = 0;
for (const col of ts.visibleColumns) {
if (!DETAIL_VALUE_MAP[col.key]) remainColSpan++;
}
return (
<TableRow key={detail.id || detail.order_no} className="cursor-pointer hover:bg-muted/50">
<TableCell />
<TableCell />
@@ -1101,19 +1117,28 @@ export default function ProductionPlanManagementPage() {
</Badge>
</div>
</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right">{formatNumber(detail.order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right">{formatNumber(detail.ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right">{formatNumber(detail.balance_qty)}</TableCell>}
<TableCell colSpan={orderColSpan - 2 - (isColVisible("total_order_qty") ? 1 : 0) - (isColVisible("total_ship_qty") ? 1 : 0) - (isColVisible("total_balance_qty") ? 1 : 0)} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
{ts.visibleColumns.map((col) => {
const detailKey = DETAIL_VALUE_MAP[col.key];
if (detailKey) {
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] text-right">{formatNumber(detail[detailKey])}</TableCell>;
}
return null;
})}
{remainColSpan > 0 && (
<TableCell colSpan={remainColSpan} className="text-[13px] text-muted-foreground">
: {detail.due_date || "-"}
</TableCell>
)}
</TableRow>
))}
);
})}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
})()}
</div>
)}
</div>
@@ -1401,8 +1426,8 @@ export default function ProductionPlanManagementPage() {
<SelectContent>
<SelectItem value="none"></SelectItem>
{equipmentList.map((eq) => (
<SelectItem key={eq.equipment_id} value={String(eq.equipment_id)}>
{eq.equipment_name} ({eq.equipment_id})
<SelectItem key={eq.id} value={eq.equipment_code || eq.id}>
{eq.equipment_name} ({eq.equipment_code})
</SelectItem>
))}
</SelectContent>
@@ -742,10 +742,24 @@ export default function PurchaseOrderPage() {
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const detailCols = ts.visibleColumns.filter(c => !MASTER_KEYS.has(c.key));
const masterCols = ts.visibleColumns.filter(c => MASTER_KEYS.has(c.key));
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// ts.visibleColumns 순서를 따르되, 마스터/디테일 컬럼을 분리
// 고정 컬럼(품목수)은 마스터 선행 컬럼 뒤에 배치
const leadingMaster: typeof ts.visibleColumns = [];
const detailCols: typeof ts.visibleColumns = [];
const trailingMaster: typeof ts.visibleColumns = [];
let passedFirstDetail = false;
for (const col of ts.visibleColumns) {
if (MASTER_KEYS.has(col.key)) {
if (passedFirstDetail) trailingMaster.push(col);
else leadingMaster.push(col);
} else {
passedFirstDetail = true;
detailCols.push(col);
}
}
const renderDetailCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
@@ -753,23 +767,35 @@ export default function PurchaseOrderPage() {
return val || "-";
};
const renderMasterHead = (col: { key: string; label: string }) => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", col.key === "status" && "text-center")}>
{col.label}
</TableHead>
);
const renderMasterCell = (col: { key: string }, m: any, purchaseNo: string) => {
if (col.key === "purchase_no") return <TableCell key={col.key} className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>;
if (col.key === "order_date") return <TableCell key={col.key} className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>;
if (col.key === "supplier_name") return <TableCell key={col.key} className="text-sm">{m.supplier_name || "-"}</TableCell>;
if (col.key === "status") return <TableCell key={col.key} className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>;
if (col.key === "memo") return <TableCell key={col.key} className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>;
return <TableCell key={col.key} />;
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{ts.isVisible("purchase_no") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("order_date") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("supplier_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{leadingMaster.map(renderMasterHead)}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
</TableHead>
))}
{ts.isVisible("status") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>}
{ts.isVisible("memo") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{trailingMaster.map(renderMasterHead)}
</TableRow>
</TableHeader>
<TableBody>
@@ -795,9 +821,7 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>}
{ts.isVisible("order_date") && <TableCell className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>}
{ts.isVisible("supplier_name") && <TableCell className="text-sm">{m.supplier_name || "-"}</TableCell>}
{leadingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
@@ -806,8 +830,7 @@ export default function PurchaseOrderPage() {
: ""}
</TableCell>
))}
{ts.isVisible("status") && <TableCell className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>}
{ts.isVisible("memo") && <TableCell className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>}
{trailingMaster.map(col => renderMasterCell(col, m, purchaseNo))}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
@@ -815,17 +838,14 @@ export default function PurchaseOrderPage() {
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell />}
{ts.isVisible("order_date") && <TableCell />}
{ts.isVisible("supplier_name") && <TableCell />}
{leadingMaster.map(col => <TableCell key={col.key} />)}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{ts.isVisible("status") && <TableCell />}
{ts.isVisible("memo") && <TableCell />}
{trailingMaster.map(col => <TableCell key={col.key} />)}
</TableRow>
))}
</React.Fragment>
@@ -617,17 +617,21 @@ export default function PurchaseItemPage() {
toast.success("다운로드 완료");
};
// EDataTable 컬럼 정의 (구매품목)
const itemColumns: EDataTableColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[80px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "standard_price", label: "구매단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// EDataTable 컬럼 정의 (구매품목) — ts.visibleColumns 기반
const COLUMN_RENDER_MAP: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]" },
size: { width: "w-[80px]" },
unit: { width: "w-[60px]" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]" },
status: { width: "w-[60px]" },
};
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
...COLUMN_RENDER_MAP[col.key],
}));
return (
<div className="flex h-full flex-col gap-3 p-4">
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -1229,47 +1229,44 @@ export default function SupplierManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const supplierColSpan = 1 + ["supplier_code", "supplier_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (공급업체 목록)
const supplierColumns: EDataTableColumn[] = [
...(isColumnVisible("supplier_code") ? [{ key: "supplier_code", label: "공급업체코드", width: "w-[120px]" }] : []),
...(isColumnVisible("supplier_name") ? [{ key: "supplier_name", label: "공급업체명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "공급업체유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (공급업체 목록) — ts.visibleColumns 순서를 따름
const supplierColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
supplier_code: { width: "w-[120px]" },
supplier_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -28,6 +28,7 @@ const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_type", label: "검사유형" },
{ key: "item_count", label: "항목수" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
@@ -420,18 +421,41 @@ export default function ItemInspectionInfoPage() {
<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>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{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) => {
{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 => r.id);
const allChecked = groupIds.every(id => checkedIds.includes(id));
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
@@ -445,21 +469,9 @@ export default function ItemInspectionInfoPage() {
<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>
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
<TableCell className="text-sm">{group.item_name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
<TableCell>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
{ts.visibleColumns.map((col) => renderCell(col.key))}
</TableRow>
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
{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 />
@@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -345,7 +345,8 @@ export default function CustomerManagementPage() {
if (!code) return "";
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const today = new Date().toISOString().split("T")[0];
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
// 품목 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
const grouped: Record<string, { master: any; details: any[] }> = {};
@@ -810,22 +811,26 @@ export default function CustomerManagementPage() {
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
const filters: any[] = [
{ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" },
];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
dataFilter: { enabled: true, filters },
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
setItemTotalCount(allItems.length);
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const SALES_CODES = ["CAT_ML8ZFVEL_1TOR"]; // 영업관리 카테고리 코드
setItemSearchResults(allItems.filter((item: any) => {
const seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const divCodes = (item.division || "").split(",").map((c: string) => c.trim());
return divCodes.some((code: string) => SALES_CODES.includes(code));
}));
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -1229,47 +1234,44 @@ export default function CustomerManagementPage() {
}
};
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"]
.filter((k) => isColumnVisible(k)).length;
// EDataTable 컬럼 정의 (거래처 목록)
const customerColumns: EDataTableColumn[] = [
...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []),
...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
label: "거래유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
}] : []),
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
}] : []),
];
// EDataTable 컬럼 정의 (거래처 목록) — ts.visibleColumns 순서를 따름
const customerColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
customer_code: { width: "w-[120px]" },
customer_name: { minWidth: "min-w-[140px]" },
division: {
width: "w-[80px]",
render: (val: any) =>
val ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
{val}
</Badge>
) : null,
},
contact_person: { width: "w-[80px]" },
contact_phone: { width: "w-[120px]" },
email: { width: "w-[160px]" },
business_number: { width: "w-[120px]" },
address: { minWidth: "min-w-[150px]" },
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
className="text-[10px] px-1.5 py-0 h-5"
>
{val}
</Badge>
) : null,
},
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 엑셀 다운로드
const handleExcelDownload = async () => {
@@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Truck, Package,
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -42,41 +42,30 @@ const formatNumber = (val: string) => {
};
const parseNumber = (val: string) => val.replace(/,/g, "");
// 마스터 헤더 레이아웃 (수주번호 뒤, 디테일 11컬럼 위에 colSpan으로 맵핑)
// 순서: 거래처 | 단가방식 | 납품처 | 납품장소 | 수주일 | 담당자 → 합계 colSpan = 11
const MASTER_BODY_LAYOUT = [
{ key: "partner_id", label: "거래처", colSpan: 2 },
{ key: "price_mode", label: "단가방식", colSpan: 1 },
{ key: "delivery_partner_id", label: "납품처", colSpan: 2 },
{ key: "delivery_address", label: "납품장소", colSpan: 2 },
{ key: "order_date", label: "수주일", colSpan: 2 },
{ key: "manager_id", label: "담당자", colSpan: 2 },
// 플랫 테이블 컬럼 정의 (마스터+디테일 통합)
const FLAT_COLUMNS = [
{ key: "order_no", label: "수주번호", source: "master" },
{ key: "partner_id", label: "거래처", source: "master" },
{ key: "order_date", label: "수주일", source: "master" },
{ key: "part_code", label: "품번", source: "detail" },
{ key: "part_name", label: "품명", source: "detail" },
{ key: "spec", label: "규격", source: "detail" },
{ key: "unit", label: "단위", source: "detail" },
{ key: "qty", label: "수량", source: "detail" },
{ key: "ship_qty", label: "출하수량", source: "detail" },
{ key: "balance_qty", label: "잔량", source: "detail" },
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "memo", label: "메모", source: "master" },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "part_code", label: "품번" },
{ key: "part_name", label: "품명" },
{ key: "spec", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "qty", label: "수량" },
{ key: "ship_qty", label: "출하수량" },
{ key: "balance_qty", label: "잔량" },
{ key: "unit_price", label: "단가" },
{ key: "amount", label: "금액" },
{ key: "currency_code", label: "통화" },
{ key: "due_date", label: "납기일" },
];
const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = [
{ key: "order_no", label: "수주번호" },
...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })),
...DETAIL_HEADER_COLS,
{ key: "memo", label: "메모" },
];
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 수주번호(1) + 디테일(11) + 메모(1) = 15
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 헤더 필터 Popover
@@ -180,8 +169,6 @@ export default function SalesOrderPage() {
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
@@ -376,25 +363,8 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
orders.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders]);
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["order_no", ...MASTER_BODY_LAYOUT.map((c) => c.key), "memo"]);
// 카테고리 코드→라벨 변환 (마스터 필터용)
const resolveMasterLabel = useCallback((key: string, code: string) => {
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
if (key === "partner_id" || key === "manager_id" || key === "price_mode") {
return categoryOptions[key]?.find((o) => o.code === code)?.label || code;
@@ -402,106 +372,60 @@ export default function SalesOrderPage() {
return code;
}, [categoryOptions]);
// 필터 + 정렬 적용된 데이터 → 그룹핑
const filteredOrderGroups = useMemo(() => {
// 1차: order_no 기준 그룹핑 (필터 전)
const allGroups: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.order_no || "_no_order";
if (!allGroups[key]) {
allGroups[key] = { master: row._master || {}, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위 필터링)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = group.master?.[colKey] ?? "";
const label = resolveMasterLabel(colKey, String(raw));
return values.has(label) || values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위 필터링)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([orderNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
})
);
return [orderNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
// 마스터 필드 정렬 → 그룹 단위
entries.sort(([, a], [, b]) => {
const av = a.master?.[key] ?? "";
const bv = b.master?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
// 디테일 필드 정렬 → 각 그룹 내 디테일 정렬
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [orders, headerFilters, sortState, resolveMasterLabel]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
// 필터 전 전체 마스터에서 고유값 추출
const seenMasters = new Map<string, any>();
orders.forEach((row) => {
if (row.order_no && row._master && !seenMasters.has(row.order_no)) {
seenMasters.set(row.order_no, row._master);
}
// 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합)
const flatRows = useMemo(() => {
return orders.map((row) => {
const master = row._master || {};
return {
...row,
partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""),
order_date: master.order_date || row.order_date || "",
memo: row.memo || master.memo || "",
};
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "order_no", label: "수주번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })), { key: "memo", label: "메모" }]) {
}, [orders, resolveLabel]);
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of FLAT_COLUMNS) {
const values = new Set<string>();
masters.forEach((m) => {
const val = m?.[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(resolveMasterLabel(col.key, String(val)));
}
flatRows.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [orders, resolveMasterLabel]);
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredFlatRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
@@ -965,111 +889,70 @@ export default function SalesOrderPage() {
</div>
</div>
{/* 데이터 테이블 (트리 구조) */}
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} /> {/* 체크박스 */}
<col style={{ width: "36px" }} /> {/* 펼침 화살표 */}
<col style={{ width: "150px" }} /> {/* 수주번호 */}
<col style={{ width: "120px" }} /> {/* 품번 / 거래처 */}
<col style={{ width: "140px" }} /> {/* 품명 / 거래처(cont) */}
<col style={{ width: "80px" }} /> {/* 규격 / 단가방식 */}
<col style={{ width: "70px" }} /> {/* 단위 / 납품처 */}
<col style={{ width: "80px" }} /> {/* 수량 / 납품처(cont) */}
<col style={{ width: "80px" }} /> {/* 출하수량 / 납품장소 */}
<col style={{ width: "80px" }} /> {/* 잔량 / 납품장소(cont) */}
<col style={{ width: "90px" }} /> {/* 단가 / 수주일 */}
<col style={{ width: "110px" }} /> {/* 금액 / 수주일(cont) */}
<col style={{ width: "60px" }} /> {/* 통화 / 담당자 */}
<col style={{ width: "100px" }} /> {/* 납기일 / 담당자(cont) */}
<col style={{ width: "120px" }} /> {/* 메모 */}
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allFilteredIds = filteredFlatRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 수주번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("order_no")}>
<span className="truncate"></span>
{sortState?.key === "order_no" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["order_no"] || []).length > 0 && (
<HeaderFilterPopover
colKey="order_no" colLabel="수주번호"
uniqueValues={masterUniqueValues["order_no"] || []}
filterValues={headerFilters["order_no"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
{FLAT_COLUMNS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
{/* 메모 (마스터) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("memo")}>
<span className="truncate"></span>
{sortState?.key === "memo" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["memo"] || []).length > 0 && (
<HeaderFilterPopover
colKey="memo" colLabel="메모"
uniqueValues={masterUniqueValues["memo"] || []}
filterValues={headerFilters["memo"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@@ -1079,7 +962,7 @@ export default function SalesOrderPage() {
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredOrderGroups).length === 0 ? (
) : filteredFlatRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -1089,200 +972,48 @@ export default function SalesOrderPage() {
</TableCell>
</TableRow>
) : (
Object.entries(filteredOrderGroups).map(([orderNo, group]) => {
const isExpanded = expandedOrders.has(orderNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
filteredFlatRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<React.Fragment key={orderNo}>
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(orderNo)) {
setClosingOrders((prev) => new Set(prev).add(orderNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(orderNo));
}
}}
onDoubleClick={() => openEditModal(orderNo)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 수주번호 */}
<TableCell className="font-mono whitespace-nowrap">
{orderNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 거래처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""}
</span>
</TableCell>
{/* 단가방식 (colSpan=1) */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""}
</span>
</TableCell>
{/* 납품처 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_partner_id || ""}</span>
</TableCell>
{/* 납품장소 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.delivery_address || ""}</span>
</TableCell>
{/* 수주일 (colSpan=2) */}
<TableCell colSpan={2} className="whitespace-nowrap text-[13px]">
{master.order_date || ""}
</TableCell>
{/* 담당자 (colSpan=2) */}
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
<span className="block truncate">
{master.manager_id ? (categoryOptions["manager_id"]?.find((o) => o.code === master.manager_id)?.label || master.manager_id) : ""}
</span>
</TableCell>
{/* 메모 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(orderNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
{DETAIL_HEADER_COLS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => d[col.key]).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
<TableCell />
</TableRow>
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(orderNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.currency_code || ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell />
</TableRow>
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
})}
</React.Fragment>
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.order_no}</TableCell>
<TableCell className="text-[13px] truncate max-w-[140px]"><span className="block truncate">{row.partner_id || ""}</span></TableCell>
<TableCell className="whitespace-nowrap text-[13px]">{row.order_date || ""}</TableCell>
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
@@ -402,32 +402,41 @@ export default function SalesItemPage() {
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
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);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/customer_item_mapping/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,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.customer_item_code || m.customer_item_name)
.map((m: any) => ({ _id: `m_existing_${m.id}`, customer_item_code: m.customer_item_code || "", customer_item_name: m.customer_item_name || "" }));
} catch { /* skip */ }
let priceRows: any[] = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/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,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
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({ _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "" });
}
setSelectedCustsForDetail([custInfo]);
@@ -782,23 +791,17 @@ export default function SalesItemPage() {
"cursor-pointer h-[41px]",
customerCheckedIds.includes(row.id) ? "bg-primary/[0.08]" : "hover:bg-accent"
)}
onClick={() => {
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditCust(row)}
>
<TableCell
className="text-center px-2"
onClick={(e) => {
e.stopPropagation();
setCustomerCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell className="text-center px-2">
<Checkbox
checked={customerCheckedIds.includes(row.id)}
onCheckedChange={(checked) => {
if (checked === true) setCustomerCheckedIds((prev) => [...prev, row.id]);
else setCustomerCheckedIds((prev) => prev.filter((id) => id !== row.id));
}}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-[13px] font-mono text-muted-foreground">{row.customer_code}</TableCell>
@@ -363,7 +363,7 @@ export default function ShippingOrderPage() {
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
planQty: item.orderQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,781 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
Plus,
Pencil,
Trash2,
Calendar,
Upload,
Ruler,
FileText,
Loader2,
Inbox,
Save,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
getDesignRequestList,
createDesignRequest,
updateDesignRequest,
deleteDesignRequest,
} from "@/lib/api/design";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
// ========== 타입 ==========
interface HistoryItem {
id?: string;
step: string;
history_date: string;
user_name: string;
description: string;
}
interface DesignRequest {
id: string;
request_no: string;
source_type: string;
request_date: string;
due_date: string;
design_type: string;
priority: string;
status: string;
approval_step: string;
target_name: string;
customer: string;
req_dept: string;
requester: string;
designer: string;
order_no: string;
spec: string;
change_type: string;
drawing_no: string;
urgency: string;
reason: string;
content: string;
apply_timing: string;
review_memo: string;
project_id: string;
ecn_no: string;
created_date: string;
updated_date: string;
writer: string;
company_code: string;
history: HistoryItem[];
impact: string[];
}
// ========== 스타일 맵 ==========
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-muted text-foreground",
: "bg-warning/10 text-warning",
: "bg-info/10 text-info",
: "bg-primary/10 text-primary",
: "bg-success/10 text-success",
: "bg-destructive/10 text-destructive",
: "bg-muted text-muted-foreground",
};
const TYPE_STYLES: Record<string, string> = {
: "bg-info/10 text-info",
: "bg-success/10 text-success",
: "bg-warning/10 text-warning",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-destructive/10 text-destructive",
: "bg-warning/10 text-warning",
: "bg-muted text-foreground",
: "bg-success/10 text-success",
};
const STATUS_PROGRESS: Record<string, number> = {
신규접수: 0,
접수대기: 0,
검토중: 20,
설계진행: 50,
설계검토: 80,
출도완료: 100,
반려: 0,
종료: 100,
};
function getProgressColor(p: number) {
if (p >= 100) return "bg-success";
if (p >= 60) return "bg-warning";
if (p >= 20) return "bg-info";
return "bg-muted";
}
function getProgressTextColor(p: number) {
if (p >= 100) return "text-success";
if (p >= 60) return "text-warning";
if (p >= 20) return "text-info";
return "text-muted-foreground";
}
const INITIAL_FORM = {
request_no: "",
request_date: "",
due_date: "",
design_type: "",
priority: "보통",
target_name: "",
customer: "",
req_dept: "",
requester: "",
designer: "",
order_no: "",
spec: "",
drawing_no: "",
content: "",
};
// ========== Grid Columns ==========
const DR_GRID_COLUMNS = [
{ key: "request_no", label: "의뢰번호" },
{ key: "design_type", label: "유형" },
{ key: "status", label: "상태" },
{ key: "priority", label: "우선순위" },
{ key: "target_name", label: "설비/제품명" },
{ key: "customer", label: "고객명" },
{ key: "designer", label: "설계담당" },
{ key: "due_date", label: "납기" },
{ key: "progress", label: "진행률" },
];
// ========== 메인 컴포넌트 ==========
export default function DesignRequestPage() {
const ts = useTableSettings("c16-design-request", "dsn_design_request", DR_GRID_COLUMNS);
const [requests, setRequests] = useState<DesignRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(INITIAL_FORM);
const today = useMemo(() => new Date(), []);
// 데이터 조회
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source_type: "dr" };
const res = await getDesignRequestList(params);
if (res.success && res.data) {
setRequests(res.data);
} else {
setRequests([]);
}
} catch {
setRequests([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
// 클라이언트 사이드 필터링 (DynamicSearchFilter)
const filteredRequests = useMemo(() => {
if (searchFilters.length === 0) return requests;
return requests.filter((item) => {
for (const f of searchFilters) {
const val = item[f.columnName as keyof DesignRequest];
const strVal = val !== undefined && val !== null ? (Array.isArray(val) ? val.join(",") : String(val)) : "";
if (f.operator === "contains") {
if (!strVal.toLowerCase().includes(f.value.toLowerCase())) return false;
} else if (f.operator === "equals") {
if (strVal !== f.value) return false;
} else if (f.operator === "in") {
const allowed = f.value.split("|");
if (!allowed.includes(strVal)) return false;
} else if (f.operator === "between") {
const [from, to] = f.value.split("|");
if (from && strVal < from) return false;
if (to && strVal > to) return false;
}
}
return true;
});
}, [requests, searchFilters]);
const selectedItem = useMemo(() => {
if (!selectedId) return null;
return requests.find((r) => r.id === selectedId) || null;
}, [selectedId, requests]);
const statusCounts = useMemo(() => {
return {
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
설계진행: requests.filter((r) => r.status === "설계진행").length,
출도완료: requests.filter((r) => r.status === "출도완료").length,
};
}, [requests]);
// 채번: 기존 데이터 기반으로 다음 번호 생성
const generateNextNo = useCallback(() => {
const year = new Date().getFullYear();
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
const maxNum = existing.reduce((max, r) => {
const parts = r.request_no?.split("-");
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
return num > max ? num : max;
}, 0);
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
}, [requests]);
const handleOpenRegister = useCallback(() => {
setIsEditMode(false);
setEditingId(null);
setForm({
...INITIAL_FORM,
request_no: generateNextNo(),
request_date: new Date().toISOString().split("T")[0],
});
setModalOpen(true);
}, [generateNextNo]);
const handleOpenEdit = useCallback(() => {
if (!selectedItem) return;
setIsEditMode(true);
setEditingId(selectedItem.id);
setForm({
request_no: selectedItem.request_no || "",
request_date: selectedItem.request_date || "",
due_date: selectedItem.due_date || "",
design_type: selectedItem.design_type || "",
priority: selectedItem.priority || "보통",
target_name: selectedItem.target_name || "",
customer: selectedItem.customer || "",
req_dept: selectedItem.req_dept || "",
requester: selectedItem.requester || "",
designer: selectedItem.designer || "",
order_no: selectedItem.order_no || "",
spec: selectedItem.spec || "",
drawing_no: selectedItem.drawing_no || "",
content: selectedItem.content || "",
});
setDetailOpen(false);
setModalOpen(true);
}, [selectedItem]);
const handleSave = useCallback(async () => {
if (!form.target_name.trim()) { toast.error("설비/제품명을 입력해 주세요."); return; }
if (!form.design_type) { toast.error("의뢰 유형을 선택해 주세요."); return; }
if (!form.due_date) { toast.error("납기를 입력해 주세요."); return; }
if (!form.spec.trim()) { toast.error("요구사양을 입력해 주세요."); return; }
setSaving(true);
try {
const payload = {
request_no: form.request_no,
source_type: "dr",
request_date: form.request_date,
due_date: form.due_date,
design_type: form.design_type,
priority: form.priority,
target_name: form.target_name,
customer: form.customer,
req_dept: form.req_dept,
requester: form.requester,
designer: form.designer,
order_no: form.order_no,
spec: form.spec,
drawing_no: form.drawing_no,
content: form.content,
};
let res;
if (isEditMode && editingId) {
res = await updateDesignRequest(editingId, payload);
} else {
res = await createDesignRequest({
...payload,
status: "신규접수",
history: [{
step: "신규접수",
history_date: form.request_date || new Date().toISOString().split("T")[0],
user_name: form.requester || "시스템",
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
}],
});
}
if (res.success) {
toast.success(isEditMode ? "수정되었어요." : "등록되었어요.");
setModalOpen(false);
await fetchRequests();
if (isEditMode && editingId) {
setSelectedId(editingId);
} else if (res.data?.id) {
setSelectedId(res.data.id);
}
} else {
toast.error(res.message || "저장에 실패했어요.");
}
} catch (err: any) {
toast.error("저장에 실패했어요.");
} finally {
setSaving(false);
}
}, [form, isEditMode, editingId, fetchRequests]);
const handleDelete = useCallback(async () => {
if (!selectedId || !selectedItem) return;
const displayNo = selectedItem.request_no || selectedId;
if (!confirm(`${displayNo} 설계의뢰를 삭제할까요?`)) return;
try {
const res = await deleteDesignRequest(selectedId);
if (res.success) {
toast.success("삭제되었어요.");
setSelectedId(null);
setDetailOpen(false);
await fetchRequests();
} else {
toast.error(res.message || "삭제에 실패했어요.");
}
} catch (err: any) {
toast.error("삭제에 실패했어요.");
}
}, [selectedId, selectedItem, fetchRequests]);
const getDueDateInfo = useCallback(
(dueDate: string) => {
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
const due = new Date(dueDate);
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
if (diff === 0) return { text: "오늘", color: "text-warning" };
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-warning" };
return { text: `${diff}일 남음`, color: "text-success" };
},
[today]
);
const getProgress = useCallback((status: string) => {
return STATUS_PROGRESS[status] ?? 0;
}, []);
const handleRowClick = useCallback((id: string) => {
setSelectedId(id);
setDetailOpen(true);
}, []);
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 */}
<div className="shrink-0">
<DynamicSearchFilter
tableName="dsn_design_request"
filterId="c16-design-request"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={filteredRequests.length}
/>
</div>
{/* 현황 카드 */}
<div className="grid grid-cols-3 gap-3 shrink-0">
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-info">{statusCounts.}</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-warning">{statusCounts.}</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-left">
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-success">{statusCounts.}</div>
</div>
</div>
{/* 액션 바 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold"> </h2>
<Badge variant="secondary" className="font-mono">{filteredRequests.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleOpenRegister}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<div className="mx-1 h-6 w-px bg-border" />
<Button variant="outline" size="sm" disabled={!selectedId} onClick={handleOpenEdit}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable<DesignRequest>
columns={ts.visibleColumns.map((col): EDataTableColumn<DesignRequest> => ({
key: col.key,
label: col.label,
width: col.key === "request_no" ? "w-[100px]" : col.key === "design_type" ? "w-[70px]" : col.key === "status" ? "w-[70px]" : col.key === "priority" ? "w-[60px]" : col.key === "customer" ? "w-[90px]" : col.key === "designer" ? "w-[70px]" : col.key === "due_date" ? "w-[85px]" : col.key === "progress" ? "w-[65px]" : undefined,
align: (col.key === "design_type" || col.key === "status" || col.key === "priority" || col.key === "progress") ? "center" : undefined,
render: col.key === "request_no"
? (val: any) => <span className="text-[11px] font-semibold text-primary">{val || "-"}</span>
: col.key === "design_type"
? (val: any) => val ? <Badge className={cn("text-[9px]", TYPE_STYLES[val])}>{val}</Badge> : <span>-</span>
: col.key === "status"
? (val: any) => <Badge className={cn("text-[9px]", STATUS_STYLES[val])}>{val}</Badge>
: col.key === "priority"
? (val: any) => <Badge className={cn("text-[9px]", PRIORITY_STYLES[val])}>{val}</Badge>
: col.key === "progress"
? (_val: any, row: DesignRequest) => {
const progress = STATUS_PROGRESS[row.status] ?? 0;
return (
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
}
: undefined,
}))}
data={ts.groupData(filteredRequests)}
loading={loading}
emptyMessage="등록된 설계의뢰가 없어요"
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
onRowClick={(row) => handleRowClick(row.id)}
draggableColumns={false}
/>
</div>
{/* 상세 정보 다이얼로그 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-[900px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{selectedItem ? `설계의뢰 상세 — ${selectedItem.request_no}` : "설계의뢰 상세"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{selectedItem && (
<div className="space-y-4">
{/* 기본 정보 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
<InfoRow
label="납기"
value={
selectedItem.due_date ? (
<span>
{selectedItem.due_date}{" "}
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
({getDueDateInfo(selectedItem.due_date).text})
</span>
</span>
) : "-"
}
/>
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
<InfoRow
label="진행률"
value={
(() => {
const progress = getProgress(selectedItem.status);
return (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
})()
}
/>
</div>
</div>
{/* 요구사양 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="rounded-lg border bg-muted/10 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
{selectedItem.drawing_no && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"> : </span>
<span className="text-primary">{selectedItem.drawing_no}</span>
</div>
)}
{selectedItem.content && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">: </span>{selectedItem.content}
</div>
)}
</div>
</div>
{/* 진행 이력 */}
{selectedItem.history && selectedItem.history.length > 0 && (
<div>
<div className="mb-2 text-xs font-bold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="space-y-0">
{selectedItem.history.map((h, idx) => {
const isLast = idx === selectedItem.history.length - 1;
const isDone = h.step === "출도완료" || h.step === "종료";
return (
<div key={h.id || idx} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
isLast && !isDone
? "border-primary bg-primary"
: isDone || !isLast
? "border-success bg-success"
: "border-muted-foreground bg-muted-foreground"
)}
/>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className="pb-3">
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
<div className="mt-0.5 text-xs">{h.description}</div>
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
{selectedItem && (
<>
<Button variant="outline" size="sm" onClick={handleOpenEdit}>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={handleDelete}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "설계의뢰 수정" : "설계의뢰 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "설계의뢰 정보를 수정해요." : "새 설계의뢰를 등록해요."}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col md:flex-row gap-6 p-6">
{/* 좌측: 기본 정보 */}
<div className="md:w-[420px] shrink-0 space-y-4">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.request_no} readOnly className="h-9 bg-muted cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">/ <span className="text-destructive">*</span></Label>
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9" />
</div>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측: 상세 내용 */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div className="flex-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Textarea
value={form.spec}
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술해주세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
className="min-h-[180px]"
/>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> </Label>
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9" />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px]" rows={3} />
</div>
<div>
<h3 className="text-sm font-semibold pb-2 border-b mb-2"></h3>
<div className="cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-1.5 text-sm text-muted-foreground"> (, , )</div>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
// ========== 정보 행 서브컴포넌트 ==========
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-1">
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{value}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,950 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Inbox, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const GRID_COLUMNS_CONFIG = [
{ key: "equipment_code", label: "설비코드" },
{ key: "equipment_name", label: "설비명" },
{ key: "equipment_type", label: "설비유형" },
{ key: "manufacturer", label: "제조사" },
{ key: "installation_location", label: "설치장소" },
{ key: "operation_status", label: "가동상태" },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 설비 등록/수정 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
// 점검항목 추가/수정 모달
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [inspectionContinuous, setInspectionContinuous] = useState(false);
const [inspectionEditMode, setInspectionEditMode] = useState(false);
// 소모품 추가/수정 모달
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableContinuous, setConsumableContinuous] = useState(false);
const [consumableEditMode, setConsumableEditMode] = useState(false);
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-equipment-info", EQUIP_TABLE, GRID_COLUMNS_CONFIG);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
equipment_code: { width: "w-[110px]" },
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetchData = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setInspections(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetchData();
}, [selectedEquip?.equipment_code]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetchData = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetchData();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (isNumeric && !inspectionForm.unit) { toast.error("측정단위가 필수입니다."); return; }
// 기준값/오차범위 → 하한치/상한치 자동 계산
const saveData = { ...inspectionForm };
if (isNumeric && saveData.standard_value) {
const std = Number(saveData.standard_value) || 0;
const tol = Number(saveData.tolerance) || 0;
saveData.lower_limit = String(std - tol);
saveData.upper_limit = String(std + tol);
}
if (!isNumeric) {
saveData.unit = "";
saveData.standard_value = "";
saveData.tolerance = "";
saveData.lower_limit = "";
saveData.upper_limit = "";
}
setSaving(true);
try {
if (inspectionEditMode) {
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code },
});
toast.success("수정되었습니다.");
setInspectionModalOpen(false);
} else {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (inspectionContinuous) {
setInspectionForm({});
} else {
setInspectionModalOpen(false);
}
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 품목 로드
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
if (consumableEditMode) {
await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, {
originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code },
});
toast.success("수정되었습니다.");
setConsumableModalOpen(false);
} else {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (consumableContinuous) {
setConsumableForm({});
} else {
setConsumableModalOpen(false);
}
}
refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 브레드크럼 */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span></span>
<span className="text-muted-foreground/50">/</span>
<span className="text-foreground font-medium"></span>
</div>
{/* 검색 바 */}
<DynamicSearchFilter
tableName={EQUIP_TABLE}
filterId="c16-equipment-info"
onFilterChange={setSearchFilters}
dataCount={equipCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{equipCount}</span>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<div className="h-4 w-px bg-border" />
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
<div className="h-4 w-px bg-border" />
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<EDataTable
columns={mainTableColumns}
data={ts.groupData(equipments)}
loading={equipLoading}
emptyMessage="등록된 설비가 없어요"
selectedId={selectedEquipId}
onSelect={(id) => setSelectedEquipId(id)}
onRowDoubleClick={() => openEquipEdit()}
showPagination={true}
draggableColumns={false}
columnOrderKey="c16-equipment-info-main"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
<Inbox className="w-12 h-12 text-muted-foreground/40" />
<div>
<p className="text-sm font-semibold text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
</div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={infoForm.introduction_date || ""} onChange={(e) => setInfoForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<div className="flex-1 overflow-auto">
{inspectionLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : inspections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardCheck className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm"> </p>
</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="min-w-[120px] 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-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[70px] 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="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</thead>
<TableBody>
{inspections.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
const std = item.standard_value || "";
const tol = item.tolerance || "";
setInspectionForm({ ...item, standard_value: std, tolerance: tol });
setInspectionEditMode(true);
setInspectionModalOpen(true);
}}>
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.inspection_content || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
) : (
<div className="flex-1 overflow-auto">
{consumableLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : consumables.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Package className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm"> </p>
</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="min-w-[120px] 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-[60px] 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>
</TableRow>
</thead>
<TableBody>
{consumables.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
setConsumableForm({ ...item });
setConsumableEditMode(true);
loadConsumableItems();
setConsumableModalOpen(true);
}}>
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.specification || "-"}</TableCell>
<TableCell className="text-[13px]">{item.manufacturer || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<Dialog open={equipModalOpen} onOpenChange={setEquipModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{equipEditMode ? "설비 수정" : "설비 등록"}</DialogTitle>
<DialogDescription>{equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input type="date" value={equipForm.introduction_date || ""} onChange={(e) => setEquipForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name} {inspectionEditMode ? "수정" : "추가"}.</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
const label = resolve("inspection_method", v);
const isNum = ["숫자", "치수검사"].includes(label) || ["숫자", "치수검사"].includes(v);
if (!isNum) {
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
} else {
setInspectionForm((p) => ({ ...p, inspection_method: v }));
}
}, "점검방법")}</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" /></div>
);
})()}
</div>
{(() => {
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
const isNumeric = ["숫자", "치수검사"].includes(methodLabel) || ["숫자", "치수검사"].includes(inspectionForm.inspection_method);
if (!isNumeric) return null;
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.standard_value || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" /></div>
<div className="space-y-1.5"><Label className="text-sm">±</Label>
<Input value={inspectionForm.tolerance || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" /></div>
</div>
);
})()}
<div className="space-y-1.5"><Label className="text-sm"></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={inspectionForm.inspection_content || ""}
onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))}
placeholder="점검 항목 및 내용 입력"
/></div>
<div className="space-y-1.5"><Label className="text-sm"> ()</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={inspectionForm.checklist || ""}
onChange={(e) => setInspectionForm((p) => ({ ...p, checklist: e.target.value }))}
placeholder="점검 체크리스트 입력 (줄바꿈으로 구분)"
/></div>
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{consumableEditMode ? "소모품 수정" : "소모품 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name} {consumableEditMode ? "수정" : "추가"}.</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3 flex-1 overflow-y-auto">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없어요" : "설비를 선택해주세요"}</div>
) : (
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px] 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-[70px]"></TableHead>
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</thead>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
{/* 테이블 설정 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,501 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Plus, Trash2, Save, Loader2, Pencil, Cpu, Settings2, Search, Inbox,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
/* ───── 테이블명 ───── */
const DATATYPE_TABLE = "plc_data_type";
const DATATYPE_COLUMNS = [
{ key: "equipment_code", label: "설비코드" },
{ key: "data_type", label: "데이터타입" },
{ key: "unit", label: "단위" },
{ key: "tag_address", label: "태그주소" },
{ key: "collection_interval", label: "수집주기" },
{ key: "lower_limit", label: "하한값" },
{ key: "upper_limit", label: "상한값" },
{ key: "is_active", label: "사용여부" },
];
const COLLECTION_TABLE = "plc_collection_config";
const EQUIPMENT_TABLE = "equipment_mng";
/* ───── Cron 한글 변환 ───── */
const cronToKorean = (cron: string): string => {
if (!cron) return "";
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return cron;
const [min, hour] = parts;
if (min === "*" && hour === "*") return "매 분마다";
if (min !== "*" && hour === "*") return `매시 ${min}분마다`;
if (min === "0" && hour !== "*") return `매일 ${hour}시 정각`;
if (min === "*/5") return "5분마다";
if (min === "*/10") return "10분마다";
if (min === "*/30") return "30분마다";
return cron;
};
/* ───── 카테고리 flatten ───── */
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flattenCategories(v.children));
}
return result;
};
export default function PlcSettingsPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-plc-settings", DATATYPE_TABLE, DATATYPE_COLUMNS);
const [activeTab, setActiveTab] = useState("datatype");
/* ───── PLC 데이터타입 ───── */
const [datatypes, setDatatypes] = useState<any[]>([]);
const [dtLoading, setDtLoading] = useState(false);
const [dtCount, setDtCount] = useState(0);
const [dtChecked, setDtChecked] = useState<string[]>([]);
const [dtModalOpen, setDtModalOpen] = useState(false);
const [dtEditMode, setDtEditMode] = useState(false);
const [dtForm, setDtForm] = useState<Record<string, any>>({});
const [dtSaving, setDtSaving] = useState(false);
const [dtKeyword, setDtKeyword] = useState("");
/* ───── 수집 설정 ───── */
const [configs, setConfigs] = useState<any[]>([]);
const [cfgLoading, setCfgLoading] = useState(false);
const [cfgCount, setCfgCount] = useState(0);
const [cfgChecked, setCfgChecked] = useState<string[]>([]);
const [cfgModalOpen, setCfgModalOpen] = useState(false);
const [cfgEditMode, setCfgEditMode] = useState(false);
const [cfgForm, setCfgForm] = useState<Record<string, any>>({});
const [cfgSaving, setCfgSaving] = useState(false);
const [cfgKeyword, setCfgKeyword] = useState("");
/* ───── FK + 카테고리 옵션 ───── */
const [equipOptions, setEquipOptions] = useState<{ code: string; label: string }[]>([]);
const [collectionTypeOptions, setCollectionTypeOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
const load = async () => {
try {
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
} catch { /* skip */ }
try {
const catRes = await apiClient.get(`/table-categories/${COLLECTION_TABLE}/collection_type/values`);
if (catRes.data?.data?.length > 0) {
setCollectionTypeOptions(flattenCategories(catRes.data.data));
}
} catch { /* skip */ }
};
load();
}, []);
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchDatatypes = useCallback(async (keyword?: string) => {
setDtLoading(true);
try {
const kw = keyword !== undefined ? keyword : dtKeyword;
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDatatypes(rows);
setDtCount(rows.length);
} catch { toast.error("PLC 데이터타입 조회에 실패했어요"); }
finally { setDtLoading(false); }
}, [dtKeyword]);
const fetchConfigs = useCallback(async (keyword?: string) => {
setCfgLoading(true);
try {
const kw = keyword !== undefined ? keyword : cfgKeyword;
const filters: any[] = [];
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setConfigs(rows);
setCfgCount(rows.length);
} catch { toast.error("수집 설정 조회에 실패했어요"); }
finally { setCfgLoading(false); }
}, [cfgKeyword]);
useEffect(() => { fetchDatatypes(); fetchConfigs(); }, []);
/* ═══════════════════ 데이터타입 CRUD ═══════════════════ */
const openDtCreate = () => { setDtForm({}); setDtEditMode(false); setDtModalOpen(true); };
const openDtEdit = (row: any) => { setDtForm({ ...row }); setDtEditMode(true); setDtModalOpen(true); };
const saveDt = async () => {
if (!dtForm.equipment_code) { toast.error("설비코드는 필수 입력이에요"); return; }
setDtSaving(true);
try {
if (dtEditMode) {
await apiClient.put(`/table-management/tables/${DATATYPE_TABLE}/edit`, {
originalData: { id: dtForm.id }, updatedData: dtForm,
});
toast.success("PLC 데이터타입을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/add`, dtForm);
toast.success("PLC 데이터타입을 등록했어요");
}
setDtModalOpen(false);
fetchDatatypes();
} catch { toast.error("저장에 실패했어요"); }
finally { setDtSaving(false); }
};
const deleteDt = async () => {
if (dtChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
const ok = await confirm("PLC 데이터타입 삭제", { description: `선택한 ${dtChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DATATYPE_TABLE}/delete`, {
data: dtChecked.map(id => ({ id })),
});
toast.success(`${dtChecked.length}건을 삭제했어요`);
setDtChecked([]);
fetchDatatypes();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ 수집 설정 CRUD ═══════════════════ */
const openCfgCreate = () => { setCfgForm({}); setCfgEditMode(false); setCfgModalOpen(true); };
const openCfgEdit = (row: any) => { setCfgForm({ ...row }); setCfgEditMode(true); setCfgModalOpen(true); };
const saveCfg = async () => {
if (!cfgForm.config_name) { toast.error("설정명은 필수 입력이에요"); return; }
setCfgSaving(true);
try {
if (cfgEditMode) {
await apiClient.put(`/table-management/tables/${COLLECTION_TABLE}/edit`, {
originalData: { id: cfgForm.id }, updatedData: cfgForm,
});
toast.success("수집 설정을 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/add`, cfgForm);
toast.success("수집 설정을 등록했어요");
}
setCfgModalOpen(false);
fetchConfigs();
} catch { toast.error("저장에 실패했어요"); }
finally { setCfgSaving(false); }
};
const deleteCfg = async () => {
if (cfgChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
const ok = await confirm("수집 설정 삭제", { description: `선택한 ${cfgChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${COLLECTION_TABLE}/delete`, {
data: cfgChecked.map(id => ({ id })),
});
toast.success(`${cfgChecked.length}건을 삭제했어요`);
setCfgChecked([]);
fetchConfigs();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
{ConfirmDialogComponent}
<div className="rounded-lg border bg-card">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="border-b px-3">
<TabsList className="bg-transparent h-auto p-0 gap-0">
<TabsTrigger
value="datatype"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
>
<Cpu className="w-4 h-4 mr-2" />
PLC
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{dtCount}</Badge>
</TabsTrigger>
<TabsTrigger
value="collection"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
>
<Settings2 className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{cfgCount}</Badge>
</TabsTrigger>
</TabsList>
</div>
{/* ──── PLC 데이터타입 탭 ──── */}
<TabsContent value="datatype" className="p-3 mt-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 w-56 text-sm"
placeholder="설비코드 검색..."
value={dtKeyword}
onChange={(e) => setDtKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchDatatypes(dtKeyword)}
/>
</div>
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchDatatypes(dtKeyword)}>
<Search className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setDtKeyword(""); fetchDatatypes(""); }}>
</Button>
<Badge variant="secondary" className="bg-primary/10 text-primary">{dtCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openDtCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = datatypes.find(r => dtChecked.includes(r.id));
if (sel) openDtEdit(sel); else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={deleteDt}><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="border rounded-lg overflow-hidden">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.key === "is_active" ? "center" : undefined,
render: col.key === "is_active"
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
: undefined,
}))}
data={ts.groupData(datatypes)}
loading={dtLoading}
emptyMessage="등록된 PLC 데이터타입이 없어요"
showCheckbox
checkedIds={dtChecked}
onCheckedChange={setDtChecked}
onRowDoubleClick={(row) => openDtEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
{/* ──── 수집 설정 탭 ──── */}
<TabsContent value="collection" className="p-3 mt-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 w-56 text-sm"
placeholder="설정명 검색..."
value={cfgKeyword}
onChange={(e) => setCfgKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchConfigs(cfgKeyword)}
/>
</div>
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchConfigs(cfgKeyword)}>
<Search className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setCfgKeyword(""); fetchConfigs(""); }}>
</Button>
<Badge variant="secondary" className="bg-primary/10 text-primary">{cfgCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openCfgCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = configs.find(r => cfgChecked.includes(r.id));
if (sel) openCfgEdit(sel); else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={deleteCfg}><Trash2 className="w-4 h-4 mr-1" /></Button>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<EDataTable
columns={[
{ key: "config_name", label: "설정명" },
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
] as EDataTableColumn[]}
data={configs}
loading={cfgLoading}
emptyMessage="등록된 수집 설정이 없어요"
showCheckbox
checkedIds={cfgChecked}
onCheckedChange={setCfgChecked}
onRowDoubleClick={(row) => openCfgEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
</Tabs>
</div>
{/* ═══════════════════ PLC 데이터타입 모달 ═══════════════════ */}
<Dialog open={dtModalOpen} onOpenChange={setDtModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{dtEditMode ? "PLC 데이터타입 수정" : "PLC 데이터타입 등록"}</DialogTitle>
<DialogDescription>PLC </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Select value={dtForm.equipment_code || ""} onValueChange={(v) => setDtForm(p => ({ ...p, equipment_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="설비를 선택해주세요" /></SelectTrigger>
<SelectContent>
{equipOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.data_type || ""} onChange={(e) => setDtForm(p => ({ ...p, data_type: e.target.value }))} placeholder="예: 온도, 압력" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.unit || ""} onChange={(e) => setDtForm(p => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, bar" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.tag_address || ""} onChange={(e) => setDtForm(p => ({ ...p, tag_address: e.target.value }))} placeholder="예: D100" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={dtForm.collection_interval || ""} onChange={(e) => setDtForm(p => ({ ...p, collection_interval: e.target.value }))} placeholder="예: 1000ms" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" type="number" value={dtForm.lower_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, lower_limit: e.target.value }))} placeholder="하한값" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" type="number" value={dtForm.upper_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, upper_limit: e.target.value }))} placeholder="상한값" />
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={dtForm.is_active ?? true} onCheckedChange={(v) => setDtForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDtModalOpen(false)}></Button>
<Button onClick={saveDt} disabled={dtSaving}>
{dtSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══════════════════ 수집 설정 모달 ═══════════════════ */}
<Dialog open={cfgModalOpen} onOpenChange={setCfgModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{cfgEditMode ? "수집 설정 수정" : "수집 설정 등록"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" value={cfgForm.config_name || ""} onChange={(e) => setCfgForm(p => ({ ...p, config_name: e.target.value }))} placeholder="설정명을 입력해주세요" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">ID</Label>
<Input className="h-9" value={cfgForm.source_connection_id || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_connection_id: e.target.value }))} placeholder="소스연결ID" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
{collectionTypeOptions.length > 0 ? (
<Select value={cfgForm.collection_type || ""} onValueChange={(v) => setCfgForm(p => ({ ...p, collection_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
<SelectContent>
{collectionTypeOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-9" value={cfgForm.collection_type || ""} onChange={(e) => setCfgForm(p => ({ ...p, collection_type: e.target.value }))} placeholder="수집유형" />
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={cfgForm.source_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_table: e.target.value }))} placeholder="소스테이블명" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9" value={cfgForm.target_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, target_table: e.target.value }))} placeholder="대상테이블명" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs font-semibold text-muted-foreground"> (Cron)</Label>
<Input className="h-9 font-mono text-sm" value={cfgForm.schedule_cron || ""} onChange={(e) => setCfgForm(p => ({ ...p, schedule_cron: e.target.value }))} placeholder="예: */5 * * * * (5분마다)" />
{cfgForm.schedule_cron && (
<p className="text-xs text-muted-foreground mt-1">{cronToKorean(cfgForm.schedule_cron)}</p>
)}
</div>
<div className="space-y-1.5 col-span-2">
<div className="flex items-center gap-2">
<Checkbox checked={cfgForm.is_active ?? true} onCheckedChange={(v) => setCfgForm(p => ({ ...p, is_active: !!v }))} />
<Label className="text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCfgModalOpen(false)}></Button>
<Button onClick={saveCfg} disabled={cfgSaving}>
{cfgSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
@@ -0,0 +1,905 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Truck,
DollarSign,
FileText,
MapPin,
Car,
Plus,
Trash2,
Download,
Pencil,
RefreshCw,
Inbox,
Loader2,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// ========== 타입 & 상수 ==========
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
interface TabColumnDef {
key: string;
label: string;
width?: string;
align?: "left" | "center" | "right";
formatNumber?: boolean;
}
interface FormFieldDef {
key: string;
label: string;
type: "text" | "number" | "select" | "smartselect" | "date";
required?: boolean;
referenceKey?: "carrier" | "route";
categoryKey?: string;
options?: { value: string; label: string }[];
placeholder?: string;
}
interface TabConfig {
key: TabKey;
label: string;
icon: React.ReactNode;
tableName: string;
columns: TabColumnDef[];
formFields: FormFieldDef[];
defaultSortColumn: string;
}
const TAB_CONFIGS: TabConfig[] = [
{
key: "carrier",
label: "운송업체",
icon: <Truck className="h-3.5 w-3.5" />,
tableName: "carrier_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "업체코드", width: "120px" },
{ key: "carrier_name", label: "업체명", width: "160px" },
{ key: "carrier_type", label: "업체유형", width: "100px" },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "contact_phone", label: "연락처", width: "130px" },
{ key: "email", label: "이메일", width: "180px" },
{ key: "address", label: "주소", width: "220px" },
{ key: "rating", label: "등급", width: "70px", align: "center" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
{ key: "carrier_type", label: "업체유형", type: "select", required: true, categoryKey: "carrier_mng:carrier_type", placeholder: "업체유형을 선택해주세요" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "예: 02-1234-5678" },
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
{ key: "rating", label: "등급", type: "select", categoryKey: "carrier_mng:rating", placeholder: "등급을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "cost",
label: "물류비",
icon: <DollarSign className="h-3.5 w-3.5" />,
tableName: "logistics_cost_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
{ key: "unit", label: "단위", width: "70px", align: "center" },
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
{ key: "min_weight", label: "최소중량", width: "100px", align: "right", formatNumber: true },
{ key: "max_weight", label: "최대중량", width: "100px", align: "right", formatNumber: true },
{ key: "delivery_days", label: "배송일수", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "route_code", label: "배송구간", type: "smartselect", required: true, referenceKey: "route" },
{ key: "base_fee", label: "기본요금", type: "number", placeholder: "0" },
{ key: "unit", label: "단위", type: "text", placeholder: "kg, 건 등" },
{ key: "unit_fee", label: "단가", type: "number", placeholder: "0" },
{ key: "min_weight", label: "최소중량", type: "number", placeholder: "0" },
{ key: "max_weight", label: "최대중량", type: "number", placeholder: "0" },
{ key: "delivery_days", label: "배송일수", type: "number", placeholder: "0" },
],
},
{
key: "contract",
label: "계약서",
icon: <FileText className="h-3.5 w-3.5" />,
tableName: "carrier_contract_mng",
defaultSortColumn: "contract_no",
columns: [
{ key: "contract_no", label: "계약번호", width: "130px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "contract_start_date", label: "시작일", width: "110px" },
{ key: "contract_end_date", label: "종료일", width: "110px" },
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "contract_no", label: "계약번호", type: "text", required: true, placeholder: "계약번호를 입력해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명을 입력해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "route",
label: "배송구간",
icon: <MapPin className="h-3.5 w-3.5" />,
tableName: "delivery_route_mng",
defaultSortColumn: "route_code",
columns: [
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "route_name", label: "구간명", width: "160px" },
{ key: "departure", label: "출발지", width: "120px" },
{ key: "destination", label: "도착지", width: "120px" },
{ key: "distance_km", label: "거리(km)", width: "100px", align: "right", formatNumber: true },
{ key: "avg_time_hours", label: "평균시간(h)", width: "100px", align: "right" },
{ key: "route_type", label: "구간유형", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
{ key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" },
{ key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" },
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "delivery_route_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "vehicle",
label: "차량",
icon: <Car className="h-3.5 w-3.5" />,
tableName: "carrier_vehicle_mng",
defaultSortColumn: "vehicle_code",
columns: [
{ key: "vehicle_code", label: "차량코드", width: "120px" },
{ key: "vehicle_number", label: "차량번호", width: "120px" },
{ key: "vehicle_type", label: "차량유형", width: "100px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
{ key: "driver_name", label: "운전자", width: "100px" },
{ key: "last_maintenance_date", label: "최종정비일", width: "110px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
{ key: "vehicle_type", label: "차량유형", type: "select", required: true, categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차량유형을 선택해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
{ key: "last_maintenance_date", label: "최종정비일", type: "date" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
],
},
];
// 카테고리 계층 평탄화
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,
});
}
if (item.children?.length) walk(item.children);
}
}
walk(items);
return result;
}
// 채번 대상 필드 매핑: tableName → 코드 필드 key
const NUMBERING_FIELD_MAP: Record<string, string> = {
carrier_mng: "carrier_code",
delivery_route_mng: "route_code",
carrier_vehicle_mng: "vehicle_code",
};
// ========== 메인 컴포넌트 ==========
export default function LogisticsInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 탭 상태
const [activeTab, setActiveTab] = useState<TabKey>("carrier");
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 탭별 독립 상태
const [tabData, setTabData] = useState<Record<TabKey, any[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
const [tabLoading, setTabLoading] = useState<Record<TabKey, boolean>>({
carrier: false, cost: false, contract: false, route: false, vehicle: false,
});
const [tabChecked, setTabChecked] = useState<Record<TabKey, string[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
// FK 참조 데이터 (캐싱)
const [carrierOptions, setCarrierOptions] = useState<{ code: string; label: string }[]>([]);
const [routeOptions, setRouteOptions] = useState<{ code: string; label: string }[]>([]);
// 카테고리 옵션 캐시
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
const loadedCategories = useRef(new Set<string>());
// 모달 상태
const [formOpen, setFormOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 테이블 설정 (탭별)
const tsCarrier = useTableSettings("c16-logistics-carrier", TAB_CONFIGS[0].tableName, TAB_CONFIGS[0].columns);
const tsCost = useTableSettings("c16-logistics-cost", TAB_CONFIGS[1].tableName, TAB_CONFIGS[1].columns);
const tsContract = useTableSettings("c16-logistics-contract", TAB_CONFIGS[2].tableName, TAB_CONFIGS[2].columns);
const tsRoute = useTableSettings("c16-logistics-route", TAB_CONFIGS[3].tableName, TAB_CONFIGS[3].columns);
const tsVehicle = useTableSettings("c16-logistics-vehicle", TAB_CONFIGS[4].tableName, TAB_CONFIGS[4].columns);
const tsMap: Record<TabKey, typeof tsCarrier> = { carrier: tsCarrier, cost: tsCost, contract: tsContract, route: tsRoute, vehicle: tsVehicle };
const activeTs = tsMap[activeTab];
const activeConfig = useMemo(
() => TAB_CONFIGS.find((c) => c.key === activeTab)!,
[activeTab]
);
// 컬럼 가시성 헬퍼
const getVisibleColumns = (tabKey: TabKey) => tsMap[tabKey].visibleColumns;
// 클라이언트 사이드 필터링
const filteredData = useMemo(() => {
const data = tabData[activeTab];
if (searchFilters.length === 0) return data;
return data.filter((row) =>
searchFilters.every((f) => {
if (!f.value) return true;
const kw = f.value.toLowerCase();
if (f.columnName) {
return String(row[f.columnName] ?? "").toLowerCase().includes(kw);
}
return Object.values(row).some((v) => String(v ?? "").toLowerCase().includes(kw));
})
);
}, [tabData, activeTab, searchFilters]);
// FK 참조 데이터 로드
const loadReferences = useCallback(async () => {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
const carriers = carrierRes.data?.data?.data || carrierRes.data?.data?.rows || [];
setCarrierOptions(
carriers.map((r: any) => ({
code: r.carrier_code || "",
label: `${r.carrier_code} - ${r.carrier_name || ""}`,
}))
);
const routes = routeRes.data?.data?.data || routeRes.data?.data?.rows || [];
setRouteOptions(
routes.map((r: any) => ({
code: r.route_code || "",
label: `${r.route_code} - ${r.route_name || ""}`,
}))
);
} catch {
// FK 참조 로드 실패 시 무시
}
}, []);
useEffect(() => {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
[tableColumn]: data.length > 0 ? flattenCategories(data) : [],
}));
} catch {
setCategoryOptions((prev) => ({ ...prev, [tableColumn]: [] }));
}
}, []);
// 활성 탭의 카테고리 로드
useEffect(() => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
config.formFields.forEach((f) => {
if (f.categoryKey) loadCategoryOptions(f.categoryKey);
});
}, [activeTab, loadCategoryOptions]);
// 데이터 조회
const fetchTabData = useCallback(async (tab: TabKey) => {
const config = TAB_CONFIGS.find((c) => c.key === tab);
if (!config) return;
setTabLoading((prev) => ({ ...prev, [tab]: true }));
try {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setTabData((prev) => ({ ...prev, [tab]: rows }));
} catch {
toast.error("데이터를 불러오는 데 실패했어요.");
setTabData((prev) => ({ ...prev, [tab]: [] }));
} finally {
setTabLoading((prev) => ({ ...prev, [tab]: false }));
}
}, []);
// 초기 데이터 로드 (탭 전환 시)
useEffect(() => {
fetchTabData(activeTab);
}, [activeTab, fetchTabData]);
// 탭 변경
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab as TabKey);
setSearchFilters([]);
}, []);
// 등록 모달 열기
const handleOpenAdd = useCallback(async () => {
setEditMode(false);
setEditId(null);
setFormData({});
setPreviewCode(null);
setNumberingRuleId(null);
setFormOpen(true);
// 현재 탭의 채번 규칙 조회
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
const codeField = NUMBERING_FIELD_MAP[config.tableName];
if (!codeField) return; // 채번 대상이 아닌 탭
try {
const ruleRes = await apiClient.get(
`/numbering-rules/by-column/${config.tableName}/${codeField}`
);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시 — 사용자가 직접 입력
}
}, [activeTab]);
// 수정 모달 열기
const handleOpenEdit = useCallback((row: any) => {
setEditMode(true);
setEditId(row.id ? String(row.id) : null);
setFormData({ ...row });
setFormOpen(true);
}, []);
// 저장
const handleSave = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
// 필수값 검증 (등록 모드에서 채번 대상 코드 필드는 자동 할당이므로 스킵)
const numberingCodeField = NUMBERING_FIELD_MAP[config.tableName];
for (const field of config.formFields) {
if (!editMode && numberingRuleId && field.key === numberingCodeField) continue;
if (field.required && !formData[field.key]?.toString().trim()) {
toast.error(`${field.label}은(는) 필수 입력이에요.`);
return;
}
}
try {
// 배송구간: 출발지→도착지 로 구간명 자동 생성
const saveData = { ...formData };
if (activeTab === "route" && saveData.departure && saveData.destination) {
saveData.route_name = `${saveData.departure}${saveData.destination}`;
}
if (editMode && editId) {
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
originalData: { id: editId },
updatedData: saveData,
});
toast.success("수정이 완료되었어요.");
} else {
// 채번 규칙이 있으면 allocate로 실제 코드 할당
const codeField = NUMBERING_FIELD_MAP[config.tableName];
if (codeField && numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
saveData[codeField] = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
await apiClient.post(
`/table-management/tables/${config.tableName}/add`,
{ id: crypto.randomUUID(), ...saveData }
);
toast.success("등록이 완료되었어요.");
}
setFormOpen(false);
fetchTabData(activeTab);
// FK 참조 테이블 변경 시 캐시 갱신
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
}
}, [activeTab, editMode, editId, formData, fetchTabData, loadReferences, numberingRuleId]);
// 삭제
const handleDelete = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
const ids = tabChecked[activeTab];
if (ids.length === 0) {
toast.error("삭제할 항목을 선택해주세요.");
return;
}
const ok = await confirm(`선택한 ${ids.length}건을 삭제할까요?`, {
description: "삭제된 데이터는 복구할 수 없어요.",
variant: "destructive",
});
if (!ok) return;
try {
await apiClient.delete(
`/table-management/tables/${config.tableName}/delete`,
{ data: ids.map((id) => ({ id })) }
);
toast.success(`${ids.length}건이 삭제되었어요.`);
setTabChecked((prev) => ({ ...prev, [activeTab]: [] }));
fetchTabData(activeTab);
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch {
toast.error("삭제에 실패했어요.");
}
}, [activeTab, tabChecked, confirm, fetchTabData, loadReferences]);
// 엑셀 다운로드 (필터된 데이터 기준)
const handleExcelDownload = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
if (filteredData.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
const exportData = filteredData.map((row) => {
const obj: Record<string, any> = {};
config.columns.forEach((col) => {
obj[col.label] = row[col.key] ?? "";
});
return obj;
});
await exportToExcel(exportData, `${config.label}.xlsx`, config.label);
toast.success("엑셀 다운로드가 완료되었어요.");
}, [activeTab, filteredData]);
// 폼 필드 변경
const updateFormField = useCallback((key: string, value: any) => {
setFormData((prev) => ({ ...prev, [key]: value }));
}, []);
// 행 체크 토글
const toggleRowCheck = useCallback((tabKey: TabKey, rowId: string) => {
setTabChecked((prev) => {
const ids = prev[tabKey];
return {
...prev,
[tabKey]: ids.includes(rowId) ? ids.filter((x) => x !== rowId) : [...ids, rowId],
};
});
}, []);
// 전체 체크 토글
const toggleAllCheck = useCallback((tabKey: TabKey, checked: boolean) => {
setTabChecked((prev) => ({
...prev,
[tabKey]: checked ? tabData[tabKey].map((r: any) => String(r.id)) : [],
}));
}, [tabData]);
// 폼 필드 렌더
const renderFormField = useCallback(
(field: FormFieldDef) => {
const value = formData[field.key] ?? "";
// 현재 탭의 채번 대상 코드 필드인지 확인
const numberingCodeField = NUMBERING_FIELD_MAP[activeConfig.tableName];
const isNumberingTarget = !editMode && numberingRuleId && field.key === numberingCodeField;
// 수정 모드에서 코드/번호 필드는 읽기전용
const isCodeField =
editMode &&
field.type === "text" &&
(field.key.endsWith("_code") || field.key.endsWith("_no"));
switch (field.type) {
case "text":
// 등록 모드 + 채번 대상 필드: readOnly로 미리보기 코드 표시
if (isNumberingTarget) {
return (
<Input
value={previewCode || ""}
readOnly
placeholder="채번 조회 중..."
className="h-9 text-sm bg-muted text-muted-foreground"
/>
);
}
return (
<Input
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
readOnly={isCodeField}
className={cn(
"h-9 text-sm",
isCodeField && "bg-muted text-muted-foreground"
)}
/>
);
case "number":
return (
<Input
type="number"
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-9 text-sm"
/>
);
case "select": {
const opts =
field.options ||
(field.categoryKey ? categoryOptions[field.categoryKey] : []) ||
[];
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "smartselect": {
// SmartSelect 대신 Select로 직접 구현
const opts =
field.referenceKey === "carrier" ? carrierOptions : routeOptions;
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "date":
return (
<Input
type="date"
value={value ? String(value).split("T")[0] : ""}
onChange={(e) => updateFormField(field.key, e.target.value)}
className="h-9 text-sm"
/>
);
default:
return null;
}
},
[formData, editMode, carrierOptions, routeOptions, categoryOptions, updateFormField, activeConfig, numberingRuleId, previewCode]
);
// ========== 렌더링 ==========
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={activeConfig.tableName}
filterId="c16-logistics-info"
onFilterChange={setSearchFilters}
externalFilterConfig={activeTs.filterConfig}
dataCount={filteredData.length}
/>
{/* 탭 + 콘텐츠 영역 */}
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card"
>
<TabsList className="h-auto w-full shrink-0 justify-start gap-0 rounded-none border-b bg-muted/30 p-0">
{TAB_CONFIGS.map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className="flex items-center gap-1.5 rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground data-[state=active]:border-primary data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
{tab.icon}
{tab.label}
<Badge
variant="outline"
className="ml-1 h-5 min-w-[22px] justify-center px-1.5 font-mono text-[10px]"
>
{tabData[tab.key]?.length || 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{TAB_CONFIGS.map((tab) => {
const displayData = tab.key === activeTab ? filteredData : tabData[tab.key];
const isAllChecked =
tabData[tab.key].length > 0 &&
tabData[tab.key].every((r: any) => tabChecked[tab.key].includes(String(r.id)));
return (
<TabsContent
key={tab.key}
value={tab.key}
className="m-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
{/* 액션 바 */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold">{tab.label} </h2>
<Badge className="bg-primary/10 font-mono text-[11px] text-primary">
{tab.key === activeTab ? displayData.length : tabData[tab.key]?.length || 0}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-8 text-xs" onClick={handleOpenAdd}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={tabChecked[tab.key].length !== 1}
onClick={() => {
const row = tabData[tab.key].find(
(r: any) => String(r.id) === tabChecked[tab.key][0]
);
if (row) handleOpenEdit(row);
}}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs text-destructive hover:bg-destructive/10"
disabled={tabChecked[tab.key].length === 0}
onClick={handleDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleExcelDownload}
>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fetchTabData(tab.key)}
>
<RefreshCw
className={cn(
"h-3.5 w-3.5",
tabLoading[tab.key] && "animate-spin"
)}
/>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => activeTs.setOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
emptyMessage={`등록된 ${tab.label} 정보가 없어요`}
showCheckbox
checkedIds={tabChecked[tab.key]}
onCheckedChange={(ids) => setTabChecked((prev) => ({ ...prev, [tab.key]: ids }))}
onRowDoubleClick={(row) => handleOpenEdit(row)}
showPagination={false}
draggableColumns={false}
/>
</div>
</TabsContent>
);
})}
</Tabs>
{/* 등록/수정 모달 */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[680px]">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>
{activeConfig.label} {editMode ? "수정" : "등록"}
</DialogTitle>
<DialogDescription>
{editMode
? `${activeConfig.label} 정보를 수정해주세요.`
: `${activeConfig.label} 정보를 입력해주세요.`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-4">
{activeConfig.formFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{field.label}
{field.required && (
<span className="ml-0.5 text-destructive">*</span>
)}
</Label>
{renderFormField(field)}
</div>
))}
</div>
</div>
<div className="shrink-0 border-t">
<div className="flex items-center justify-end gap-2 px-6 py-3">
<Button variant="outline" onClick={() => setFormOpen(false)}>
</Button>
<Button onClick={handleSave}>
{editMode ? "수정" : "등록"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={activeTs.open}
onOpenChange={activeTs.setOpen}
tableName={activeTs.tableName}
settingsId={activeTs.settingsId}
defaultVisibleKeys={activeTs.defaultVisibleKeys}
onSave={activeTs.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,738 @@
"use client";
/**
* (Type B -)
*
* 좌측: 재고 (inventory_stock, item_info JOIN)
* 우측: 선택 (inventory_history)
*
* / ,
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Package,
Loader2,
Download,
ClipboardEdit,
History,
AlertTriangle,
RefreshCw,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
const STOCK_TABLE = "inventory_stock";
const STOCK_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "warehouse_code", label: "창고" },
{ key: "location_code", label: "위치" },
{ key: "current_qty", label: "현재수량", align: "right" as const },
{ key: "safety_qty", label: "안전재고", align: "right" as const },
{ key: "unit", label: "단위" },
{ key: "status", label: "상태" },
];
const HISTORY_TABLE = "inventory_history";
const getStatusVariant = (
status: string
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "정상":
return "default";
case "부족":
return "destructive";
case "과잉":
return "secondary";
default:
return "outline";
}
};
const getHistoryTypeVariant = (
type: string
): "default" | "secondary" | "outline" | "destructive" => {
switch (type) {
case "입고":
return "default";
case "출고":
return "secondary";
case "조정":
return "outline";
case "입고취소":
case "이동":
return "destructive";
default:
return "outline";
}
};
export default function InventoryStatusPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
// 좌측: 재고 목록
const [stockItems, setStockItems] = useState<any[]>([]);
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 우측: 이동 이력
const [historyItems, setHistoryItems] = useState<any[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// 조정 모달
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
const [adjustForm, setAdjustForm] = useState<{
adjust_type: string;
adjust_qty: string;
reason: string;
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
const [adjustSaving, setAdjustSaving] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
Record<string, { code: string; label: string }[]>
>({});
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["status", "unit"]) {
try {
const res = await apiClient.get(
`/table-categories/${STOCK_TABLE}/${col}/values`
);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch {
/* skip */
}
}
setCategoryOptions(optMap);
};
load();
}, []);
// 재고 목록 조회
const fetchStock = useCallback(async () => {
setStockLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const [stockRes, itemRes, whRes] = await Promise.all([
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "item_code", order: "asc" },
}),
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
]);
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }]));
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const itemInfo = itemMap.get(r.item_code) as any;
return {
...r,
item_name: itemInfo?.name || "",
unit: itemInfo?.unit || resolve("unit", r.unit),
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
status: resolve("status", r.status),
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
};
});
setStockItems(data);
} catch {
toast.error("재고 목록을 불러오지 못했어요");
} finally {
setStockLoading(false);
}
}, [categoryOptions, searchFilters]);
useEffect(() => {
fetchStock();
}, [fetchStock]);
// 선택된 재고
const selectedStock = stockItems.find((s) => s.id === selectedStockId);
// 이력 조회
const fetchHistory = useCallback(async () => {
if (!selectedStock?.item_code) {
setHistoryItems([]);
return;
}
setHistoryLoading(true);
try {
const historyFilters: any[] = [
{
columnName: "item_code",
operator: "equals",
value: selectedStock.item_code,
},
];
if (selectedStock.warehouse_code) {
historyFilters.push({
columnName: "warehouse_code",
operator: "equals",
value: selectedStock.warehouse_code,
});
}
const res = await apiClient.post(
`/table-management/tables/${HISTORY_TABLE}/data`,
{
page: 1,
size: 500,
dataFilter: { enabled: true, filters: historyFilters },
autoFilter: true,
sort: { columnName: "transaction_date", order: "desc" },
}
);
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setHistoryItems(raw);
} catch {
toast.error("재고 이력을 불러오지 못했어요");
} finally {
setHistoryLoading(false);
}
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
// 재고 조정 저장
const handleAdjustSave = async () => {
if (!selectedStock) return;
const qty = Number(adjustForm.adjust_qty);
if (!qty || qty <= 0) {
toast.error("조정 수량을 입력해주세요");
return;
}
if (!adjustForm.reason.trim()) {
toast.error("조정 사유를 입력해주세요");
return;
}
setAdjustSaving(true);
try {
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
const afterQty = Number(selectedStock.current_qty || 0) + changeQty;
await apiClient.post(
`/table-management/tables/${HISTORY_TABLE}/add`,
{
id: crypto.randomUUID(),
item_code: selectedStock.item_code,
warehouse_code: selectedStock.warehouse_code || "",
location_code: selectedStock.location_code || "",
transaction_type: "조정",
transaction_date: new Date().toISOString(),
quantity: String(changeQty),
balance_qty: String(afterQty),
remark: adjustForm.reason.trim(),
}
);
await apiClient.put(
`/table-management/tables/${STOCK_TABLE}/edit`,
{
originalData: { id: selectedStock.id },
updatedData: { current_qty: afterQty },
}
);
toast.success("재고가 조정되었어요");
setAdjustModalOpen(false);
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
fetchStock();
} catch {
toast.error("재고 조정에 실패했어요");
} finally {
setAdjustSaving(false);
}
};
// EDataTable 컬럼 정의
const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align };
if (col.key === "current_qty") {
return {
...base,
align: "right" as const,
render: (val: any, row: any) => (
<span className="font-mono">
<span className={cn(row._isLow && "text-destructive font-bold")}>
{Number(row.current_qty || 0).toLocaleString()}
</span>
{row._isLow && (
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
)}
</span>
),
};
}
if (col.key === "safety_qty") {
return {
...base,
align: "right" as const,
formatNumber: true,
};
}
if (col.key === "status") {
return {
...base,
render: (val: any) => (
<Badge variant={getStatusVariant(val)} className="text-[10px]">
{val}
</Badge>
),
};
}
return base;
});
// 엑셀 내보내기
const handleExcelExport = () => {
if (stockItems.length === 0) {
toast.error("내보낼 데이터가 없어요");
return;
}
exportToExcel(
stockItems.map((r) => ({
품목코드: r.item_code,
품명: r.item_name,
창고: r.warehouse_name || r.warehouse_code,
위치: r.location_code,
현재수량: r.current_qty,
안전재고: r.safety_qty,
단위: r.unit,
상태: r.status,
})),
"재고현황"
);
};
return (
<div className="flex flex-col h-full gap-3 p-3">
{/* 검색 바 */}
<DynamicSearchFilter
tableName={STOCK_TABLE}
filterId="c16-inventory"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={stockItems.length}
extraActions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={handleExcelExport}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
}
/>
{/* 마스터-디테일 패널 */}
<ResizablePanelGroup
direction="horizontal"
className="flex-1 rounded-lg border bg-card"
>
{/* 좌측: 재고 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-[13px] font-bold"> </span>
<Badge variant="default" className="rounded-full text-[11px]">
{stockItems.length}
</Badge>
</div>
</div>
<EDataTable
columns={stockColumns}
data={ts.groupData(stockItems)}
rowKey={(row) => row.id}
loading={stockLoading}
emptyMessage="등록된 재고가 없어요"
selectedId={selectedStockId}
onSelect={(id) => setSelectedStockId(id)}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-inventory"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 이력 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
{!selectedStock ? (
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
<p className="text-sm font-semibold text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
</p>
</div>
) : (
<>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-bold">
{selectedStock.item_name || selectedStock.item_code}
</span>
<Badge
variant="outline"
className="rounded-full text-[11px] font-mono"
>
{selectedStock.item_code}
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">:</span>
<span
className={cn(
"font-bold font-mono",
selectedStock._isLow
? "text-destructive"
: "text-foreground"
)}
>
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
{selectedStock._isLow && (
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
setAdjustForm({
adjust_type: "증가",
adjust_qty: "",
reason: "",
});
setAdjustModalOpen(true);
}}
>
<ClipboardEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 재고 요약 카드 */}
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.safety_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold truncate max-w-full">
{selectedStock.warehouse_name || "-"}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<Badge
variant={getStatusVariant(selectedStock.status)}
className="text-[10px] mt-0.5"
>
{selectedStock.status || "-"}
</Badge>
</div>
</div>
{/* 이력 서브헤더 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">
</span>
<Badge variant="secondary" className="rounded-full text-[10px]">
{historyItems.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={fetchHistory}
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
{/* 이력 테이블 */}
<div className="flex-1 overflow-auto">
{historyLoading ? (
<div className="flex items-center justify-center h-20">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : historyItems.length === 0 ? (
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyItems.map((h, idx) => (
<TableRow key={h.id || idx} className="text-xs">
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-mono">
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
</TableCell>
<TableCell>
<Badge
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
className="text-[10px]"
>
{h.transaction_type || h.history_type}
</Badge>
</TableCell>
<TableCell
className={cn(
"text-right font-mono",
Number(h.quantity ?? h.change_qty) > 0
? "text-primary"
: "text-destructive"
)}
>
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="font-mono truncate max-w-[120px]">
{h.reference_number || h.reference_no || ""}
</TableCell>
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 재고 조정 Dialog */}
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{selectedStock
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
: ""}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.adjust_type}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="증가"> ( )</SelectItem>
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Input
type="number"
min={1}
placeholder="수량을 입력해주세요"
value={adjustForm.adjust_qty}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
adjust_qty: e.target.value,
}))
}
/>
{adjustForm.adjust_qty && selectedStock && (
<p className="text-xs text-muted-foreground">
:{" "}
<span className="font-mono font-bold">
{(
Number(selectedStock.current_qty || 0) +
(adjustForm.adjust_type === "증가" ? 1 : -1) *
Number(adjustForm.adjust_qty || 0)
).toLocaleString()}
</span>
</p>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
*
</Label>
<Textarea
placeholder="조정 사유를 입력해주세요"
rows={3}
value={adjustForm.reason}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
reason: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdjustModalOpen(false)}
>
</Button>
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
{adjustSaving && (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,629 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Card 제거 — rounded-lg border bg-card 패턴 사용
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
Loader2,
Inbox,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "plan_qty", label: "수량" },
{ key: "plan_date", label: "일자" },
{ key: "status", label: "상태" },
];
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
};
export default function MaterialStatusPage() {
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
const today = new Date();
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
// 창고 목록 초기 로드
useEffect(() => {
(async () => {
const res = await getWarehouses();
if (res.success && res.data) {
setWarehouses(res.data);
}
})();
}, []);
// 작업지시 검색
const handleSearch = useCallback(async () => {
setWorkOrdersLoading(true);
try {
const res = await getWorkOrders({
dateFrom: searchDateFrom,
dateTo: searchDateTo,
itemCode: searchItemCode || undefined,
itemName: searchItemName || undefined,
});
if (res.success && res.data) {
setWorkOrders(res.data);
setCheckedWoIds([]);
setSelectedWoId(null);
setMaterials([]);
}
} finally {
setWorkOrdersLoading(false);
}
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
// 초기 로드
useEffect(() => {
handleSearch();
}, []);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
// 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
setMaterialsLoading(true);
try {
const res = await getMaterialStatus({
planIds: checkedWoIds,
warehouseCode: warehouse || undefined,
});
if (res.success && res.data) {
setMaterials(res.data);
}
} finally {
setMaterialsLoading(false);
}
}, [checkedWoIds, warehouse]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 영역 */}
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground/50 text-xs">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Search className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 작업지시 리스트 */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{workOrders.length}
</span>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{materialsLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrdersLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : workOrders.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
{ts.isVisible("status") && (
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2.5 shrink-0">
<Factory className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
{/* 필터 */}
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5 shrink-0">
<Input
placeholder="원자재 검색"
className="h-8 min-w-[150px] flex-1 text-xs"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{warehouses.map((wh) => (
<SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{materialsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : materials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
)
: 100;
return (
<div
key={material.code}
className={cn(
"rounded-lg border p-3 transition-all hover:shadow-sm",
isShortage
? "border-destructive/30 bg-destructive/5"
: "border-primary/15 bg-primary/5"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-primary">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-primary"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
{material.locations.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
"use client";
/**
* Type D (2)
*
* Tab 1: 회사정보 (company_mng )
* Tab 2: 부서관리 (dept_info + user_info )
*/
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";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const COMPANY_TABLE = "company_mng";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
/* ── 트리 노드 타입 ── */
interface DeptNode {
dept_code: string;
dept_name: string;
parent_dept_code: string | null;
status?: string;
children: DeptNode[];
}
export default function CompanyPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
/* ===================== Tab 1: 회사정보 ===================== */
const [companyData, setCompanyData] = useState<Record<string, any>>({});
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
const [companyLoading, setCompanyLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
// 이미지 업로드 refs
const imageRef = useRef<HTMLInputElement>(null);
const logoRef = useRef<HTMLInputElement>(null);
const sealRef = useRef<HTMLInputElement>(null);
// 이미지 미리보기
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [sealPreview, setSealPreview] = useState<string | null>(null);
const fetchCompany = useCallback(async () => {
setCompanyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
page: 1, size: 1, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
if (rows.length > 0) {
setCompanyData(rows[0]);
setCompanyForm(rows[0]);
if (rows[0].company_image) setImagePreview(rows[0].company_image);
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
}
} catch {
toast.error("회사 정보를 불러오는데 실패했어요.");
} finally {
setCompanyLoading(false);
}
}, []);
useEffect(() => { fetchCompany(); }, [fetchCompany]);
const handleImageUpload = (
e: React.ChangeEvent<HTMLInputElement>,
field: string,
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreview(result);
setCompanyForm((prev) => ({ ...prev, [field]: result }));
};
reader.readAsDataURL(file);
};
const handleCompanySave = async () => {
if (!companyForm.company_name) {
toast.error("회사명은 필수예요.");
return;
}
setSaving(true);
try {
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
if (companyData.company_code) {
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
originalData: { company_code: companyData.company_code },
updatedData,
});
} else {
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
}
toast.success("회사 정보가 저장되었어요.");
setEditMode(false);
fetchCompany();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setCompanyForm(companyData);
setImagePreview(companyData.company_image || null);
setLogoPreview(companyData.company_logo || null);
setSealPreview(companyData.company_seal || null);
setEditMode(false);
};
/* ===================== Tab 2: 부서관리 ===================== */
const [depts, setDepts] = useState<any[]>([]);
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
// 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [deptSaving, setDeptSaving] = useState(false);
// 채번
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 트리 구성
const buildTree = (flatDepts: any[]): DeptNode[] => {
const map: Record<string, DeptNode> = {};
const roots: DeptNode[] = [];
flatDepts.forEach((d) => {
map[d.dept_code] = { ...d, children: [] };
});
flatDepts.forEach((d) => {
const node = map[d.dept_code];
if (d.parent_dept_code && map[d.parent_dept_code]) {
map[d.parent_dept_code].children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setDepts(raw);
setDeptTree(buildTree(raw));
// 전부 펼치기
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
} catch {
toast.error("부서 목록을 불러오는데 실패했어요.");
} finally {
setDeptLoading(false);
}
}, []);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
// 사원 조회
const fetchMembers = useCallback(async () => {
if (!selectedDeptCode) { setMembers([]); return; }
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 트리 토글
const toggleExpand = (code: string) => {
setExpandedDepts((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code); else next.add(code);
return next;
});
};
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch { /* 채번 규칙 없으면 무시 */ }
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수예요."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setDeptSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했어요."); return; }
toast.success("수정되었어요.");
} else {
const companyCode = user?.companyCode || "";
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else { toast.error("채번 코드 할당에 실패했어요."); return; }
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) { toast.error((response as any).error || "등록에 실패했어요."); return; }
toast.success("등록되었어요.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제할까요?", {
description: "해당 부서에 소속된 사원 정보는 유지돼요.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했어요."); return; }
toast.success((response as any).message || "삭제되었어요.");
setSelectedDeptCode(null);
fetchDepts();
} catch { toast.error("삭제에 실패했어요."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수예요."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수예요."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수예요."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setDeptSaving(true);
try {
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었어요." : "사원이 추가되었어요.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
// EDataTable 컬럼 정의 (사원 목록)
const companyMemberColumns: EDataTableColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "email", label: "이메일" },
];
/* ── 트리 렌더 ── */
const renderTree = (nodes: DeptNode[], depth = 0) => {
return nodes.map((node) => {
const isExpanded = expandedDepts.has(node.dept_code);
const isSelected = selectedDeptCode === node.dept_code;
const hasChildren = node.children.length > 0;
return (
<div key={node.dept_code}>
<div
className={cn(
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
!isSelected && "border-l-2 border-transparent",
)}
style={{ paddingLeft: `${12 + depth * 20}px` }}
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
>
{hasChildren ? (
<button
className="p-0.5 rounded hover:bg-accent"
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
>
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="w-4.5" />
)}
{isExpanded && hasChildren
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
: <Folder className="w-4 h-4 text-muted-foreground" />
}
<span className="truncate">{node.dept_name}</span>
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0"></Badge>}
</div>
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
</div>
);
});
};
/* ── 이미지 업로드 박스 ── */
const ImageUploadBox = ({
label, preview, inputRef, field, setPreview,
}: {
label: string;
preview: string | null;
inputRef: React.RefObject<HTMLInputElement | null>;
field: string;
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
}) => (
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{preview ? (
<>
<img src={preview} alt={label} className="w-full h-full object-contain" />
{editMode && (
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
setPreview(null);
setCompanyForm((prev) => ({ ...prev, [field]: null }));
}}>
<X className="w-3 h-3" />
</Button>
</div>
)}
</>
) : (
<button
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => editMode && inputRef.current?.click()}
disabled={!editMode}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageUpload(e, field, setPreview)}
/>
</div>
);
return (
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
{/* 탭 컨테이너 */}
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
{/* 탭 헤더 — border-b 스타일 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="company"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
{/* ===================== Tab 1: 회사정보 ===================== */}
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
<div className="border rounded-lg bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span> </span>
</div>
<div className="flex gap-1.5">
{editMode ? (
<>
<Button variant="outline" size="sm" onClick={cancelEdit}></Button>
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
</Button>
</>
) : (
<Button size="sm" onClick={() => setEditMode(true)}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{companyLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="p-6 space-y-6">
{/* 기본 정보 섹션 제목 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={companyForm.company_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
placeholder="회사명을 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.business_registration_number || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
placeholder="000-00-00000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
placeholder="대표자명"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_phone || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
placeholder="02-0000-0000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.fax || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
placeholder="02-0000-0001"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.email || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
placeholder="example@company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.website || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
placeholder="https://www.company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.address || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
placeholder="회사 주소를 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
</div>
{/* 이미지 섹션 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-6">
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정해요." : "새로운 부서를 등록해요."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
className="h-9" disabled readOnly
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 사원 추가/수정 모달 ── */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정해요.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가해요.` : "사원을 추가해요."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.end_date || ""} onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))} className="h-9" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,772 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const DEPT_COLUMNS = [
{ key: "parent_dept_code", label: "상위부서" },
{ key: "status", label: "상태" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 사원 탭 (재직중/퇴사)
const [memberTab, setMemberTab] = useState<"active" | "resigned">("active");
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-department", DEPT_TABLE, DEPT_COLUMNS);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
toast.success("수정되었습니다.");
} else {
const companyCode = user?.companyCode || "";
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) {
toast.error((response as any).error || "등록에 실패했습니다.");
return;
}
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
toast.success(response.message || "삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
end_date: userForm.end_date || null,
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
// 퇴사일 기반 재직/퇴사 분리
const _now = new Date();
const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
// EDataTable 컬럼 정의 (부서 목록) — ts.visibleColumns 순서를 따름
const deptColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
dept_code: { width: "w-[120px]" },
dept_name: { minWidth: "min-w-[140px]" },
parent_dept_code: {
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
},
status: {
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
},
};
// dept_code, dept_name은 항상 표시 (DEPT_COLUMNS에 포함되지 않으므로 visibleColumns에 없음)
const fixedCols: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", ...colProps["dept_code"] },
{ key: "dept_name", label: "부서명", ...colProps["dept_name"] },
];
const dynamicCols = ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
return [...fixedCols, ...dynamicCols];
}, [ts.visibleColumns]);
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="c16-department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-8" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => void handleExcelDownload()}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 마스터-디테일 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{deptCount}
</span>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={() => void openDeptRegister()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={() => void handleDeptDelete()}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 부서 테이블 */}
<EDataTable
columns={deptColumns}
data={ts.groupData(depts)}
rowKey={(row) => row.id}
loading={deptLoading}
emptyMessage="등록된 부서가 없어요"
selectedId={selectedDeptId}
onSelect={(id) => setSelectedDeptId(id)}
onRowDoubleClick={() => openDeptEdit()}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-department"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
{!selectedDeptId ? (
/* 빈 상태 */
<div className="flex-1 flex items-center justify-center p-5">
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
<Users className="w-12 h-12 text-muted-foreground/40 mb-4" />
<div className="text-sm font-semibold text-muted-foreground mb-1.5"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
) : (
<>
{/* 디테일 헤더 */}
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted shrink-0">
<span className="text-[13px] font-bold">{selectedDept?.dept_name || "-"}</span>
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{selectedDept?.dept_code || "-"}
</span>
<div className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 재직/퇴사 탭 */}
<div className="flex border-b border-border px-4 shrink-0 bg-muted">
<button
onClick={() => setMemberTab("active")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "active" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{activeMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{activeMembers.length}</Badge>
)}
</button>
<button
onClick={() => setMemberTab("resigned")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "resigned" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{resignedMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{resignedMembers.length}</Badge>
)}
</button>
</div>
<div className="flex-1 overflow-auto">
{memberLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
</div>
) : memberTab === "active" ? (
activeMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] 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="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : (
resignedMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] 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="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resignedMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.end_date ? member.end_date.substring(0, 10) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
className="h-9"
disabled
readOnly
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select
value={deptForm.parent_dept_code || ""}
onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="상위부서 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={() => void handleDeptSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가/수정 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
ID <span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_id || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID를 입력해 주세요"
className="h-9"
disabled={userEditMode}
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.sabun || ""}
onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번"
className="h-9"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.user_password || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"}
className="h-9"
type="password"
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.position_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9">
<SelectValue placeholder="부서를 선택해 주세요" />
</SelectTrigger>
<SelectContent>
{depts.map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.cell_phone || ""}
onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.cell_phone && "border-destructive")}
/>
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.email || ""}
onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.regdate ? userForm.regdate.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.end_date ? userForm.end_date.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))}
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={() => void handleUserSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,596 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { exportToExcel } from "@/lib/utils/excelExport";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
Pencil, Copy, Settings2, Check, ChevronsUpDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ImageUpload } from "@/components/common/ImageUpload";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { toast } from "sonner";
// 검색 가능한 카테고리 콤보박스
function CategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.code === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TABLE_NAME = "item_info";
const GRID_COLUMNS = [
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "image", label: "이미지", type: "image" },
{ key: "division", label: "관리품목" },
{ key: "type", label: "품목구분" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
{ key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true },
{ key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true },
{ key: "weight", label: "중량", align: "right" as const },
{ key: "inventory_unit", label: "재고단위" },
{ key: "user_type01", label: "대분류" },
{ key: "user_type02", label: "중분류" },
{ key: "lead_time", label: "생산 리드타임(일)", align: "right" as const },
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "image", label: "품목 이미지", type: "image" },
{ key: "meno", label: "메모", type: "textarea" },
];
const CATEGORY_COLUMNS = [
"division", "type", "unit", "material", "status",
"inventory_unit", "currency_code", "user_type01", "user_type02",
];
export default function ItemInfoPage() {
const { user } = useAuth();
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 옵션 로드
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 99999,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const resolve = (col: string, code: string) => {
if (!code) return "";
// 쉼표 구분 다중값 지원
if (code.includes(",")) {
return code.split(",").map((c) => {
const trimmed = c.trim();
if (!trimmed || trimmed === "s") return "";
return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed;
}).filter(Boolean).join(", ");
}
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했어요.");
} finally {
setLoading(false);
}
}, [categoryOptions, searchFilters]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/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 || "";
}
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 수정 모달 열기
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = async (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
const { id, item_number, created_date, updated_date, writer, ...rest } = raw;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력이에요.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었어요.");
} else {
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...insertFields });
toast.success("등록되었어요.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해 주세요.");
return;
}
if (!confirm("선택한 품목을 삭제할까요?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었어요.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했어요.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) {
row[col.label] = item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-0">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-item-info"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
/>
{/* 액션 바 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{items.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}
>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}
>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 메인 테이블 */}
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: (col as any).type === "image" ? "center" : col.align as "left" | "center" | "right" | undefined,
formatNumber: (col as any).formatNumber,
width: (col as any).type === "image" ? "w-[50px]" : undefined,
render: (col as any).type === "image" ? (val: any) => (
val ? (
<img src={String(val).startsWith("http") || String(val).startsWith("/") ? val : `/api/files/preview/${val}`} alt="" className="h-8 w-8 rounded object-cover border border-border mx-auto" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
) : <div className="h-8 w-8 rounded bg-muted mx-auto" />
) : undefined,
}))}
data={ts.groupData(items)}
loading={loading}
emptyMessage="등록된 품목이 없어요"
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
onRowDoubleClick={(row) => openEditModal(row)}
showRowNumber
draggableColumns={false}
/>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 p-6">
{FORM_FIELDS.map((field) => (
<div
key={field.key}
className={cn("space-y-1.5", (field.type === "textarea" || field.type === "image") && "col-span-2")}
>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
<ImageUpload
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
tableName={TABLE_NAME}
recordId={formData.id || ""}
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.-]/g, "");
setFormData((prev) => ({ ...prev, [field.key]: raw }));
}}
placeholder={field.placeholder || field.label}
className="h-9 text-right"
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
</div>
<DialogFooter className="shrink-0 border-t px-6 py-3">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving
? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
: <Save className="w-4 h-4 mr-1.5" />
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Settings2, Tags, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
const TABS = [
{ id: "category", label: "카테고리 설정", icon: Tags },
{ id: "numbering", label: "코드 설정", icon: Hash },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function OptionsSettingPage() {
const [activeTab, setActiveTab] = useState<TabId>("category");
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
const [selectedTableName, setSelectedTableName] = useState("");
const [leftWidth, setLeftWidth] = useState(340);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(() => {
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setLeftWidth(Math.max(260, Math.min(500, e.clientX - rect.left)));
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging]);
return (
<div className="flex h-full flex-col p-3 gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
<h1 className="text-sm font-semibold"> </h1>
</div>
<div className="flex bg-muted rounded-md p-0.5 gap-0.5">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
</button>
);
})}
</div>
</div>
<div className="flex-1 min-h-0">
{activeTab === "category" && (
<div ref={containerRef} className="flex h-full">
<div
style={{ width: leftWidth }}
className="shrink-0 border rounded-lg bg-card overflow-hidden"
>
<CategoryColumnList
tableName=""
selectedColumn={selectedColumn}
onColumnSelect={(uniqueKey, label, tableName) => {
setSelectedColumn(uniqueKey);
setSelectedColumnLabel(label);
setSelectedTableName(tableName);
}}
/>
</div>
<div
onMouseDown={handleMouseDown}
className={cn(
"w-1.5 mx-0.5 cursor-col-resize rounded-full transition-colors shrink-0",
isDragging ? "bg-primary" : "bg-border hover:bg-primary/50"
)}
/>
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
{selectedColumn && selectedTableName ? (
<CategoryValueManager
tableName={selectedTableName}
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
columnLabel={selectedColumnLabel}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-2">
<Tags className="h-8 w-8 mx-auto text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === "numbering" && (
<div className="h-full border rounded-lg bg-card overflow-auto">
<NumberingRuleDesigner />
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
"use client";
/**
*
*
* (equipment_mng) + (work_instruction)
*
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
RefreshCw,
Clock,
Loader2,
Inbox,
Wrench,
Zap,
Pause,
Power,
} from "lucide-react";
/* ───── 상태 정의 ───── */
type OperationStatus = "running" | "idle" | "maintenance" | "off" | "unknown";
interface StatusConfig {
label: string;
color: string;
bg: string;
border: string;
bar: string;
icon: React.ReactNode;
badgeBg: string;
badgeText: string;
cardGlow: string;
}
const STATUS_MAP: Record<OperationStatus, StatusConfig> = {
running: {
label: "가동중",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
bar: "bg-emerald-400",
icon: <Zap className="h-4 w-4" />,
badgeBg: "bg-emerald-500/20",
badgeText: "text-emerald-300",
cardGlow: "shadow-emerald-500/5",
},
idle: {
label: "대기",
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/30",
bar: "bg-amber-400",
icon: <Pause className="h-4 w-4" />,
badgeBg: "bg-amber-500/20",
badgeText: "text-amber-300",
cardGlow: "shadow-amber-500/5",
},
maintenance: {
label: "점검/수리",
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/30",
bar: "bg-red-400",
icon: <Wrench className="h-4 w-4" />,
badgeBg: "bg-red-500/20",
badgeText: "text-red-300",
cardGlow: "shadow-red-500/5",
},
off: {
label: "비가동",
color: "text-gray-400",
bg: "bg-gray-500/10",
border: "border-gray-500/30",
bar: "bg-gray-500",
icon: <Power className="h-4 w-4" />,
badgeBg: "bg-gray-500/20",
badgeText: "text-gray-400",
cardGlow: "shadow-gray-500/5",
},
unknown: {
label: "미설정",
color: "text-gray-500",
bg: "bg-gray-500/10",
border: "border-gray-600/30",
bar: "bg-gray-600",
icon: <Power className="h-4 w-4" />,
badgeBg: "bg-gray-600/20",
badgeText: "text-gray-500",
cardGlow: "",
},
};
/** operation_status 값 → 내부 키 매핑 */
function resolveStatus(raw: string | null | undefined): OperationStatus {
if (!raw) return "unknown";
const v = raw.trim().toLowerCase();
if (["running", "가동", "가동중"].includes(v)) return "running";
if (["idle", "대기"].includes(v)) return "idle";
if (["maintenance", "점검", "수리", "점검/수리", "점검중"].includes(v)) return "maintenance";
if (["off", "비가동", "정지"].includes(v)) return "off";
return "unknown";
}
/* ───── 타입 ───── */
interface Equipment {
id: string;
equipment_code: string;
equipment_name: string;
equipment_type: string;
installation_location: string;
operation_status: string;
manufacturer: string;
model_name: string;
image_path: string;
}
interface WorkInstruction {
id: string;
instruction_number: string;
item_name: string;
equipment_id: string;
worker_name: string;
status: string;
}
/* ───── 컴포넌트 ───── */
export default function EquipmentMonitoringPage() {
const [equipments, setEquipments] = useState<Equipment[]>([]);
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
const autoRefreshRef = useRef(autoRefresh);
// autoRefreshRef 동기화
useEffect(() => {
autoRefreshRef.current = autoRefresh;
}, [autoRefresh]);
/* ── 시간 업데이트 ── */
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
/* ── 데이터 fetch ── */
const fetchData = useCallback(async () => {
try {
setLoading(true);
const [equipRes, wiRes] = await Promise.all([
apiClient.post("/table-management/tables/equipment_mng/data", {
autoFilter: true,
page: 1,
size: 500,
}),
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
]);
const eqRows: Equipment[] = equipRes.data?.data?.rows ?? equipRes.data?.rows ?? [];
setEquipments(eqRows);
const wiRows: WorkInstruction[] = wiRes.data?.data ?? wiRes.data?.rows ?? [];
setWorkInstructions(wiRows);
} catch (err) {
console.error("설비 모니터링 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
/* ── 자동 갱신 (30초) ── */
useEffect(() => {
const interval = setInterval(() => {
if (autoRefreshRef.current) fetchData();
}, 30000);
return () => clearInterval(interval);
}, [fetchData]);
/* ── 요약 통계 ── */
const stats = useMemo(() => {
const counts: Record<OperationStatus, number> = {
running: 0,
idle: 0,
maintenance: 0,
off: 0,
unknown: 0,
};
equipments.forEach((eq) => {
const s = resolveStatus(eq.operation_status);
counts[s]++;
});
return { total: equipments.length, ...counts };
}, [equipments]);
/* ── 필터된 설비 ── */
const filteredEquipments = useMemo(() => {
if (filterStatus === "all") return equipments;
return equipments.filter((eq) => resolveStatus(eq.operation_status) === filterStatus);
}, [equipments, filterStatus]);
/* ── 설비별 작업지시 맵 ── */
const wiMap = useMemo(() => {
const map: Record<string, WorkInstruction[]> = {};
workInstructions.forEach((wi) => {
if (wi.equipment_id) {
if (!map[wi.equipment_id]) map[wi.equipment_id] = [];
map[wi.equipment_id].push(wi);
}
});
return map;
}, [workInstructions]);
/* ── 가동률 (모킹 — 센서 미연동) ── */
const getUtilization = (eq: Equipment): number | null => {
const s = resolveStatus(eq.operation_status);
if (s === "running") return 75 + Math.floor(Math.random() * 20); // 75~94
if (s === "idle") return 20 + Math.floor(Math.random() * 30); // 20~49
if (s === "maintenance") return 0;
if (s === "off") return 0;
return null;
};
/* ── 요약 카드 배열 ── */
const summaryCards: {
label: string;
count: number;
status: OperationStatus | "total";
color: string;
bg: string;
border: string;
icon: React.ReactNode;
}[] = [
{
label: "전체설비",
count: stats.total,
status: "total",
color: "text-blue-400",
bg: "bg-blue-500/10",
border: "border-blue-500/30",
icon: <Inbox className="h-5 w-5" />,
},
{
label: "가동중",
count: stats.running,
status: "running",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/30",
icon: <Zap className="h-5 w-5" />,
},
{
label: "대기",
count: stats.idle,
status: "idle",
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/30",
icon: <Pause className="h-5 w-5" />,
},
{
label: "점검/수리",
count: stats.maintenance,
status: "maintenance",
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/30",
icon: <Wrench className="h-5 w-5" />,
},
{
label: "비가동",
count: stats.off + stats.unknown,
status: "off",
color: "text-gray-400",
bg: "bg-gray-500/10",
border: "border-gray-500/30",
icon: <Power className="h-5 w-5" />,
},
];
/* ── 필터 pill ── */
const filterPills: { label: string; value: OperationStatus | "all"; color: string }[] = [
{ label: "전체", value: "all", color: "bg-blue-500/20 text-blue-300 hover:bg-blue-500/30" },
{ label: "가동중", value: "running", color: "bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30" },
{ label: "대기", value: "idle", color: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30" },
{ label: "점검/수리", value: "maintenance", color: "bg-red-500/20 text-red-300 hover:bg-red-500/30" },
{ label: "비가동", value: "off", color: "bg-gray-500/20 text-gray-300 hover:bg-gray-500/30" },
];
/* ── 포맷 ── */
const formatTime = (d: Date) =>
d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
const formatDate = (d: Date) =>
d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
/* ────────────── 렌더 ────────────── */
return (
<div className="min-h-screen bg-gray-950 text-white p-4 md:p-6 space-y-5">
{/* ── 헤더 ── */}
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
<h1 className="text-2xl font-bold tracking-tight"></h1>
</div>
<div className="flex items-center gap-3">
{/* 현재 시간 */}
<div className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800/60 rounded-lg px-3 py-1.5 border border-gray-700/50">
<Clock className="h-4 w-4" />
<span className="font-mono">{formatDate(currentTime)}</span>
<span className="font-mono text-white">{formatTime(currentTime)}</span>
</div>
{/* 자동갱신 토글 */}
<Button
variant="outline"
size="sm"
className={cn(
"border-gray-700 text-xs gap-1.5",
autoRefresh
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20"
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
)}
onClick={() => setAutoRefresh((v) => !v)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600")} />
{autoRefresh ? "ON" : "OFF"}
</Button>
{/* 새로고침 */}
<Button
variant="outline"
size="sm"
className="border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700 gap-1.5"
onClick={fetchData}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</header>
{/* ── 요약 카드 5개 ── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{summaryCards.map((card) => (
<button
key={card.label}
onClick={() =>
setFilterStatus(card.status === "total" ? "all" : (card.status as OperationStatus))
}
className={cn(
"relative flex items-center gap-3 rounded-xl border px-4 py-3 transition-all hover:scale-[1.02]",
card.bg,
card.border,
"hover:shadow-lg"
)}
>
<div className={cn("flex items-center justify-center rounded-lg p-2", card.bg, card.color)}>
{card.icon}
</div>
<div className="text-left">
<p className="text-xs text-gray-500">{card.label}</p>
<p className={cn("text-2xl font-bold tabular-nums", card.color)}>{card.count}</p>
</div>
</button>
))}
</div>
{/* ── 필터 pill ── */}
<div className="flex flex-wrap gap-2">
{filterPills.map((pill) => (
<button
key={pill.value}
onClick={() => setFilterStatus(pill.value)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-all",
filterStatus === pill.value
? cn(pill.color, "ring-1 ring-white/20")
: "bg-gray-800/60 text-gray-500 hover:text-gray-300 hover:bg-gray-800"
)}
>
{pill.label}
</button>
))}
<span className="ml-auto text-sm text-gray-600 self-center">
{filteredEquipments.length}
</span>
</div>
{/* ── 로딩 ── */}
{loading && equipments.length === 0 && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-gray-600" />
<span className="ml-3 text-gray-500"> ...</span>
</div>
)}
{/* ── 데이터 없음 ── */}
{!loading && equipments.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-600">
<Inbox className="h-12 w-12 mb-3" />
<p className="text-lg"> .</p>
</div>
)}
{/* ── 설비 카드 그리드 ── */}
{filteredEquipments.length > 0 && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}
>
{filteredEquipments.map((eq) => {
const status = resolveStatus(eq.operation_status);
const cfg = STATUS_MAP[status];
const utilization = getUtilization(eq);
const eqWIs = wiMap[eq.id] ?? [];
return (
<div
key={eq.id}
className={cn(
"relative overflow-hidden rounded-xl border bg-gray-900/80 backdrop-blur transition-all hover:shadow-lg",
cfg.border,
cfg.cardGlow
)}
>
{/* 좌측 색상 바 */}
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-xl", cfg.bar)} />
{/* 상단: 설비명 + 상태 배지 */}
<div className="flex items-start justify-between px-4 pt-3 pb-2 pl-5">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-white truncate">
{eq.equipment_name || "이름 없음"}
</h3>
<p className="text-xs text-gray-500 mt-0.5 truncate">
{eq.equipment_type || "-"} · {eq.installation_location || "-"}
</p>
</div>
<Badge
className={cn(
"ml-2 shrink-0 border-0 gap-1 text-xs font-medium",
cfg.badgeBg,
cfg.badgeText
)}
>
{cfg.icon}
{cfg.label}
</Badge>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 정보 그리드 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-4 pl-5 py-2.5 text-sm">
<div>
<span className="text-gray-500 text-xs"> </span>
<p className="text-white font-medium">-</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium">-</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium">
{eqWIs.length > 0 && eqWIs[0].worker_name
? eqWIs[0].worker_name
: "-"}
</p>
</div>
<div>
<span className="text-gray-500 text-xs"></span>
<p className="text-white font-medium truncate">{eq.equipment_code || "-"}</p>
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 가동률 프로그레스 */}
<div className="px-4 pl-5 py-2.5">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-gray-500"></span>
<span className={cn("font-bold tabular-nums", utilization !== null ? cfg.color : "text-gray-600")}>
{utilization !== null ? `${utilization}%` : "-"}
</span>
</div>
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
{utilization !== null && (
<div
className={cn("h-full rounded-full transition-all duration-700", cfg.bar)}
style={{ width: `${utilization}%` }}
/>
)}
</div>
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 현재 작업지시 */}
<div className="px-4 pl-5 py-2.5">
<p className="text-xs text-gray-500 mb-1"> </p>
{eqWIs.length > 0 ? (
<div className="space-y-1">
{eqWIs.slice(0, 2).map((wi) => (
<div key={wi.id} className="flex items-center gap-2 text-sm">
<span className="text-blue-400 font-mono text-xs shrink-0">
{wi.instruction_number || "-"}
</span>
<span className="text-gray-300 truncate">
{wi.item_name || "-"}
</span>
</div>
))}
{eqWIs.length > 2 && (
<p className="text-xs text-gray-600">+{eqWIs.length - 2} </p>
)}
</div>
) : (
<p className="text-sm text-gray-600 italic"> </p>
)}
</div>
{/* 구분선 */}
<div className="mx-4 ml-5 border-t border-gray-800/80" />
{/* 센서 데이터 (PLC 미연동) */}
<div className="flex items-center gap-4 px-4 pl-5 py-2.5 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-gray-600"></span>
<span className="text-gray-500 font-mono">-</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-600"></span>
<span className="text-gray-500 font-mono">-</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-gray-600">RPM</span>
<span className="text-gray-500 font-mono">-</span>
</div>
</div>
</div>
);
})}
</div>
)}
{/* 필터 결과 없음 */}
{!loading && equipments.length > 0 && filteredEquipments.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-gray-600">
<Inbox className="h-10 w-10 mb-2" />
<p> .</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,504 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
RefreshCw,
Clock,
Loader2,
Inbox,
Timer,
CheckCircle2,
AlertTriangle,
TrendingUp,
Play,
Pause,
} from "lucide-react";
// ─── 타입 정의 ─────────────────────────────────────────────
interface WorkInstructionDetail {
item_name?: string;
spec?: string;
customer_name?: string;
}
interface WorkInstruction {
id: string;
wi_id?: string;
work_instruction_no: string;
status: string; // 일반 / 긴급
qty: number;
completed_qty: number;
start_date: string | null;
end_date: string | null;
worker: string | null;
equipment_id: string | null;
equipment_name?: string | null;
details?: WorkInstructionDetail[];
}
interface ProcessStep {
wo_id: string;
process_name: string;
status: string; // acceptable / completed
seq_no: number;
}
type FilterTab = "전체" | "대기" | "진행중" | "완료";
// ─── 유틸리티 ──────────────────────────────────────────────
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "-";
try {
const d = new Date(dateStr);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} catch {
return dateStr;
}
}
function formatTime(date: Date): string {
const h = String(date.getHours()).padStart(2, "0");
const m = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${h}:${m}:${s}`;
}
// 작업지시별 공정현황으로 진행상태 계산
function computeProgress(
wiId: string,
processMap: Map<string, ProcessStep[]>
): "대기" | "진행중" | "완료" {
const steps = processMap.get(wiId);
if (!steps || steps.length === 0) return "대기";
const completedCount = steps.filter((s) => s.status === "completed").length;
if (completedCount === 0) return "대기";
if (completedCount === steps.length) return "완료";
return "진행중";
}
// ─── 메인 컴포넌트 ────────────────────────────────────────
export default function ProductionMonitoringPage() {
const [workInstructions, setWorkInstructions] = useState<WorkInstruction[]>([]);
const [processMap, setProcessMap] = useState<Map<string, ProcessStep[]>>(new Map());
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
// ─── 실시간 시계 ─────────────────────────────────────────
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// ─── 데이터 로드 ─────────────────────────────────────────
const fetchData = useCallback(async () => {
try {
setLoading(true);
// 작업지시 목록 조회
const wiRes = await apiClient.get("/work-instruction/list");
const wiRaw: WorkInstruction[] =
wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
const seen = new Set<string>();
const wiData = wiRaw.filter((wi) => {
const key = wi.work_instruction_no || wi.id;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setWorkInstructions(wiData);
// 공정현황 조회 (실패해도 작업지시는 표시)
try {
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
page: 1, size: 1000, autoFilter: true,
});
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
// wo_id별로 그룹핑 후, 같은 seq_no+process_name은 1건으로 합침 (배치 실행 이력 중복 제거)
const rawMap = new Map<string, ProcessStep[]>();
rows.forEach((row) => {
const key = String(row.wo_id);
if (!rawMap.has(key)) rawMap.set(key, []);
rawMap.get(key)!.push(row);
});
const map = new Map<string, ProcessStep[]>();
rawMap.forEach((steps, woId) => {
// seq_no + process_name 기준으로 대표 1건만 (completed 우선)
const grouped = new Map<string, ProcessStep>();
for (const s of steps) {
const gk = `${s.seq_no}_${s.process_name}`;
const existing = grouped.get(gk);
if (!existing || (s.status === "completed" && existing.status !== "completed")) {
grouped.set(gk, s);
}
}
const deduped = Array.from(grouped.values()).sort((a, b) => (a.seq_no ?? 0) - (b.seq_no ?? 0));
map.set(woId, deduped);
});
setProcessMap(map);
} catch {
// 공정현황 조회 실패 → 빈 맵 유지
setProcessMap(new Map());
}
} catch (err) {
console.error("생산모니터링 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// ─── 자동갱신 (30초) ─────────────────────────────────────
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchData, 30000);
return () => clearInterval(timer);
}, [autoRefresh, fetchData]);
// ─── 통계 계산 ───────────────────────────────────────────
const stats = useMemo(() => {
let waiting = 0;
let inProgress = 0;
let completed = 0;
let totalQty = 0;
let completedQty = 0;
workInstructions.forEach((wi) => {
const progress = computeProgress(wi.wi_id || wi.id, processMap);
if (progress === "대기") waiting++;
else if (progress === "진행중") inProgress++;
else completed++;
totalQty += Number((wi as any).total_qty || wi.qty || 0);
completedQty += Number(wi.completed_qty) || 0;
});
const achievementRate = totalQty > 0 ? Math.round((completedQty / totalQty) * 100) : 0;
return { waiting, inProgress, completed, achievementRate };
}, [workInstructions, processMap]);
// ─── 필터링된 작업 목록 ──────────────────────────────────
const filteredInstructions = useMemo(() => {
return workInstructions.filter((wi) => {
if (activeTab === "전체") return true;
const progress = computeProgress(wi.wi_id || wi.id, processMap);
return progress === activeTab;
});
}, [workInstructions, processMap, activeTab]);
// ─── 렌더링 ──────────────────────────────────────────────
return (
<div className="flex flex-col h-full min-h-0 bg-background p-4 gap-4 overflow-auto">
{/* 헤더 */}
<div className="flex items-center justify-between flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground"></h1>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-muted-foreground text-sm">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchData}
disabled={loading}
className="gap-1.5"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} />
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className="gap-1.5"
>
{autoRefresh ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{autoRefresh ? "ON" : "OFF"}
</Button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-4 gap-4 flex-shrink-0">
<SummaryCard
icon={<Timer className="w-5 h-5" />}
label="대기중"
value={stats.waiting}
colorClass="text-amber-500 bg-amber-500/10 border-amber-500/20"
/>
<SummaryCard
icon={<Loader2 className="w-5 h-5" />}
label="진행중"
value={stats.inProgress}
colorClass="text-blue-500 bg-blue-500/10 border-blue-500/20"
/>
<SummaryCard
icon={<CheckCircle2 className="w-5 h-5" />}
label="완료"
value={stats.completed}
colorClass="text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
/>
<SummaryCard
icon={<TrendingUp className="w-5 h-5" />}
label="달성율"
value={`${stats.achievementRate}%`}
colorClass="text-purple-500 bg-purple-500/10 border-purple-500/20"
/>
</div>
{/* 탭 필터 */}
<div className="flex items-center gap-2 flex-shrink-0">
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? "default" : "outline"}
size="sm"
onClick={() => setActiveTab(tab)}
className={cn(
"min-w-[64px]",
activeTab === tab && tab === "대기" && "bg-amber-500 hover:bg-amber-600 text-white",
activeTab === tab && tab === "진행중" && "bg-blue-500 hover:bg-blue-600 text-white",
activeTab === tab && tab === "완료" && "bg-emerald-500 hover:bg-emerald-600 text-white"
)}
>
{tab}
{tab === "전체" && ` (${workInstructions.length})`}
{tab === "대기" && ` (${stats.waiting})`}
{tab === "진행중" && ` (${stats.inProgress})`}
{tab === "완료" && ` (${stats.completed})`}
</Button>
))}
</div>
{/* 로딩 상태 */}
{loading && workInstructions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-10 h-10 animate-spin mb-3" />
<span className="text-sm"> ...</span>
</div>
)}
{/* 빈 상태 */}
{!loading && filteredInstructions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Inbox className="w-12 h-12 mb-3" />
<span className="text-sm">
{activeTab === "전체"
? "등록된 작업지시가 없습니다."
: `"${activeTab}" 상태의 작업지시가 없습니다.`}
</span>
</div>
)}
{/* 작업 카드 그리드 */}
{filteredInstructions.length > 0 && (
<div
className="grid gap-4 flex-1"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(420px, 1fr))" }}
>
{filteredInstructions.map((wi, idx) => (
<WorkCard
key={wi.id || `wi-${idx}`}
instruction={wi}
steps={processMap.get(wi.wi_id || wi.id) || []}
progress={computeProgress(wi.wi_id || wi.id, processMap)}
/>
))}
</div>
)}
</div>
);
}
// ─── 요약 카드 ─────────────────────────────────────────────
function SummaryCard({
icon,
label,
value,
colorClass,
}: {
icon: React.ReactNode;
label: string;
value: number | string;
colorClass: string;
}) {
return (
<div className="bg-card border rounded-lg p-4 flex items-center gap-4">
<div className={cn("p-2.5 rounded-lg border", colorClass)}>{icon}</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-2xl font-bold text-foreground">{value}</span>
</div>
</div>
);
}
// ─── 작업 카드 ─────────────────────────────────────────────
function WorkCard({
instruction: wi,
steps,
progress,
}: {
instruction: WorkInstruction;
steps: ProcessStep[];
progress: "대기" | "진행중" | "완료";
}) {
// API 응답은 flat 구조 (details 배열 아님)
const itemName = (wi as any).item_name || "-";
const spec = (wi as any).item_spec || "-";
const customerName = (wi as any).customer_name || "-";
// 진척률 (total_qty 또는 qty)
const totalQty = Number((wi as any).total_qty || wi.qty || 0);
const completedQty = Number(wi.completed_qty || 0);
const progressPercent = totalQty > 0 ? Math.min(100, Math.round((completedQty / totalQty) * 100)) : 0;
// 공정 현황 계산
const completedSteps = steps.filter((s) => s.status === "completed").length;
const currentStep = steps.find((s) => s.status !== "completed");
// 프로그레스바 색상
const barColor =
progressPercent >= 100
? "bg-emerald-500"
: progressPercent >= 50
? "bg-blue-500"
: "bg-amber-500";
// 상태 배지 스타일
const statusBadge: Record<string, string> = {
"대기": "bg-amber-500/10 text-amber-500 border-amber-500/30",
"진행중": "bg-blue-500/10 text-blue-500 border-blue-500/30",
"완료": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
};
const isUrgent = wi.status === "긴급";
return (
<div className="bg-card border rounded-lg overflow-hidden flex flex-col">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-foreground">
{wi.work_instruction_no}
</span>
{isUrgent && (
<Badge
variant="outline"
className="bg-red-500/10 text-red-500 border-red-500/30 text-xs gap-1"
>
<AlertTriangle className="w-3 h-3" />
</Badge>
)}
</div>
<Badge variant="outline" className={cn("text-xs", statusBadge[progress])}>
{progress}
</Badge>
</div>
{/* 카드 본문 - 정보 */}
<div className="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm border-b">
<InfoRow label="품목명" value={itemName} />
<InfoRow label="규격" value={spec} />
<InfoRow label="거래처" value={customerName} />
<InfoRow label="작업자" value={wi.worker || "-"} />
<InfoRow label="납기일" value={formatDate(wi.end_date)} />
<InfoRow label="설비" value={wi.equipment_name || wi.equipment_id || "-"} />
</div>
{/* 공정현황 */}
<div className="px-4 py-3 border-b">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-medium"></span>
{steps.length > 0 && (
<span className="text-xs text-muted-foreground">
{completedSteps}/{steps.length}
{currentStep && (
<span>
{" "}
· : <span className="text-blue-400">{currentStep.process_name}</span>
</span>
)}
</span>
)}
</div>
{steps.length > 0 ? (
<div className="flex gap-1 flex-wrap">
{steps.map((step, idx) => {
const isDone = step.status === "completed";
const isCurrent = !isDone && idx === completedSteps;
return (
<span
key={`${step.wo_id}-${step.seq_no}-${idx}`}
className={cn(
"px-2 py-0.5 rounded text-xs font-medium transition-all",
isDone && "bg-emerald-500/20 text-emerald-400",
isCurrent && "bg-blue-500/20 text-blue-400 animate-pulse",
!isDone && !isCurrent && "bg-muted text-muted-foreground"
)}
>
{step.process_name}
</span>
);
})}
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
{/* 프로그레스바 */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">
{completedQty} / {totalQty}
</span>
<span
className={cn(
"text-xs font-bold",
progressPercent >= 100
? "text-emerald-500"
: progressPercent >= 50
? "text-blue-500"
: "text-amber-500"
)}
>
{progressPercent}%
</span>
</div>
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</div>
);
}
// ─── 정보 행 ───────────────────────────────────────────────
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-muted-foreground shrink-0">{label}:</span>
<span className="text-foreground truncate">{value}</span>
</div>
);
}
@@ -0,0 +1,512 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
RefreshCw,
Clock,
Loader2,
Inbox,
Search,
ClipboardCheck,
} from "lucide-react";
/* ───── 타입 ───── */
interface ProcessRow {
id: number;
wo_id: number;
process_code: string;
process_name: string;
status: string;
plan_qty: number;
input_qty: number;
good_qty: number;
defect_qty: number;
started_at: string | null;
completed_at: string | null;
worker_name: string;
}
interface InspectionRow {
no: number;
inspectionNo: string;
inspectionType: string;
itemName: string;
spec: string;
inspectionQty: number;
goodQty: number;
defectQty: number;
defectRate: number;
result: "합격" | "불합격" | "대기";
inspectorName: string;
inspectedAt: string;
remark: string;
}
/* ───── 탭 정의 ───── */
const TABS = [
{ key: "all", label: "전체" },
{ key: "process", label: "공정검사" },
{ key: "incoming", label: "입고검사" },
{ key: "shipping", label: "출하검사" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
/* ───── 유틸 ───── */
const fmt = (n: number) => n.toLocaleString("ko-KR");
const pct = (n: number) =>
`${n.toFixed(1)}%`;
const badgeVariant = (
type: "result" | "type" | "defectRate",
value: string | number,
) => {
if (type === "result") {
if (value === "합격")
return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (value === "불합격") return "bg-red-100 text-red-700 border-red-200";
return "bg-amber-100 text-amber-700 border-amber-200";
}
if (type === "type") {
if (value === "공정검사")
return "bg-purple-100 text-purple-700 border-purple-200";
if (value === "입고검사") return "bg-blue-100 text-blue-700 border-blue-200";
return "bg-emerald-100 text-emerald-700 border-emerald-200";
}
// defectRate
const rate = typeof value === "number" ? value : parseFloat(String(value));
if (rate > 3) return "text-red-600 font-semibold";
if (rate >= 1) return "text-amber-600 font-semibold";
return "text-emerald-600";
};
/* ───── 컴포넌트 ───── */
export default function QualityMonitoringPage() {
const [processData, setProcessData] = useState<ProcessRow[]>([]);
const [loading, setLoading] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ───── 시계 ───── */
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
/* ───── 데이터 조회 ───── */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/work_order_process/data",
{ autoFilter: true },
);
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
setProcessData(rows);
} catch (err) {
console.error("품질점검현황 데이터 조회 실패:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
/* ───── 자동 갱신 ───── */
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(fetchData, 30_000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [autoRefresh, fetchData]);
/* ───── 검사 행 변환 ───── */
const inspectionRows: InspectionRow[] = useMemo(() => {
const today = new Date().toISOString().slice(0, 10);
return processData
.filter((r) => {
// 금일 데이터만
const dt = r.completed_at || r.started_at || "";
return dt.slice(0, 10) === today;
})
.map((r, idx) => {
const inspQty = r.input_qty || r.plan_qty || 0;
const goodQty = r.good_qty ?? 0;
const defectQty = r.defect_qty ?? 0;
const defectRate = inspQty > 0 ? (defectQty / inspQty) * 100 : 0;
const result: InspectionRow["result"] =
r.status !== "completed"
? "대기"
: defectQty > 0
? "불합격"
: "합격";
return {
no: idx + 1,
inspectionNo: `QC-${String(r.id).padStart(8, "0").slice(0, 8)}`,
inspectionType: "공정검사",
itemName: r.process_name || "-",
spec: r.process_code || "-",
inspectionQty: inspQty,
goodQty,
defectQty,
defectRate,
result,
inspectorName: r.worker_name || "-",
inspectedAt: r.completed_at || r.started_at || "-",
remark: "",
};
});
}, [processData]);
/* ───── 탭 필터링 ───── */
const filteredRows = useMemo(() => {
if (activeTab === "all" || activeTab === "process") return inspectionRows;
// 입고/출하는 데이터 없음
return [];
}, [activeTab, inspectionRows]);
/* ───── 요약 통계 ───── */
const summary = useMemo(() => {
const total = inspectionRows.length;
const passed = inspectionRows.filter((r) => r.result === "합격").length;
const failed = inspectionRows.filter((r) => r.result === "불합격").length;
const pending = inspectionRows.filter((r) => r.result === "대기").length;
const passRate = total > 0 ? (passed / total) * 100 : 0;
return { total, passed, failed, pending, passRate };
}, [inspectionRows]);
/* ───── 요약 카드 정의 ───── */
const summaryCards = [
{
label: "금일 검사건수",
value: fmt(summary.total),
sub: "건",
color: "from-slate-500 to-slate-600",
textColor: "text-white",
},
{
label: "합격",
value: fmt(summary.passed),
sub: "건",
color: "from-emerald-500 to-emerald-600",
textColor: "text-white",
},
{
label: "불합격",
value: fmt(summary.failed),
sub: "건",
color: "from-red-500 to-red-600",
textColor: "text-white",
},
{
label: "검사대기",
value: fmt(summary.pending),
sub: "건",
color: "from-amber-500 to-amber-600",
textColor: "text-white",
},
{
label: "합격률",
value: pct(summary.passRate),
sub: "",
color: "from-purple-500 to-purple-600",
textColor: "text-white",
},
];
/* ───── 렌더링 ───── */
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
{/* ── 헤더 ── */}
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b">
<div className="flex items-center gap-3">
<ClipboardCheck className="h-6 w-6 text-emerald-600" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{" "}
<span className="text-emerald-600"></span>
</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock className="h-4 w-4" />
<span className="font-mono">
{currentTime.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchData}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="ml-1"></span>
</Button>
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh((p) => !p)}
className={cn(
autoRefresh &&
"bg-emerald-600 hover:bg-emerald-700 text-white",
)}
>
<Clock className="h-4 w-4 mr-1" />
{autoRefresh ? "ON" : "OFF"}
</Button>
</div>
</div>
{/* ── 본문 ── */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* 요약 카드 */}
<div className="grid grid-cols-5 gap-4">
{summaryCards.map((card) => (
<div
key={card.label}
className={cn(
"rounded-xl bg-gradient-to-br p-5 shadow-md",
card.color,
)}
>
<p className="text-sm font-medium text-white/80">
{card.label}
</p>
<p className={cn("mt-2 text-3xl font-bold", card.textColor)}>
{card.value}
{card.sub && (
<span className="ml-1 text-base font-normal text-white/70">
{card.sub}
</span>
)}
</p>
</div>
))}
</div>
{/* 검사유형 탭 */}
<div className="flex items-center gap-2">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-1.5 rounded-full text-sm font-medium transition-colors",
activeTab === tab.key
? "bg-emerald-600 text-white shadow"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border",
)}
>
{tab.label}
</button>
))}
</div>
{/* 테이블 영역 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border overflow-hidden">
{/* 입고/출하 준비중 */}
{(activeTab === "incoming" || activeTab === "shipping") ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Search className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1">
{activeTab === "incoming" ? "입고검사" : "출하검사"}
.
</p>
</div>
) : loading && filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Loader2 className="h-10 w-10 animate-spin mb-4" />
<p> ...</p>
</div>
) : filteredRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
<Inbox className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium"> </p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-gray-700/50">
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[90px] text-center">
</TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[80px] text-right">
</TableHead>
<TableHead className="min-w-[70px] text-right">
</TableHead>
<TableHead className="min-w-[160px] text-center">
</TableHead>
<TableHead className="min-w-[70px] text-center">
</TableHead>
<TableHead className="min-w-[80px] text-center">
</TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const goodPct =
row.inspectionQty > 0
? (row.goodQty / row.inspectionQty) * 100
: 0;
const defectPct =
row.inspectionQty > 0
? (row.defectQty / row.inspectionQty) * 100
: 0;
return (
<TableRow
key={row.no}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30"
>
<TableCell className="text-center text-sm text-gray-500">
{row.no}
</TableCell>
<TableCell className="font-mono text-sm">
{row.inspectionNo}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={cn(
"text-xs",
badgeVariant("type", row.inspectionType),
)}
>
{row.inspectionType}
</Badge>
</TableCell>
<TableCell className="text-sm font-medium">
{row.itemName}
</TableCell>
<TableCell className="text-sm text-gray-500">
{row.spec}
</TableCell>
<TableCell className="text-right text-sm">
{fmt(row.inspectionQty)}
</TableCell>
<TableCell className="text-right text-sm text-emerald-600">
{fmt(row.goodQty)}
</TableCell>
<TableCell className="text-right text-sm text-red-600">
{fmt(row.defectQty)}
</TableCell>
<TableCell
className={cn(
"text-right text-sm",
badgeVariant("defectRate", row.defectRate),
)}
>
{pct(row.defectRate)}
</TableCell>
{/* 검사결과 프로그레스바 */}
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-gray-100 dark:bg-gray-600 rounded-full overflow-hidden flex">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${goodPct}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${defectPct}%` }}
/>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap w-[42px] text-right">
{pct(goodPct)}
</span>
</div>
</TableCell>
{/* 판정 배지 */}
<TableCell className="text-center">
<Badge
variant="outline"
className={cn(
"text-xs",
badgeVariant("result", row.result),
)}
>
{row.result}
</Badge>
</TableCell>
<TableCell className="text-center text-sm">
{row.inspectorName}
</TableCell>
<TableCell className="text-sm text-gray-500">
{row.inspectedAt !== "-"
? new Date(row.inspectedAt).toLocaleString(
"ko-KR",
{
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)
: "-"}
</TableCell>
<TableCell className="text-sm text-gray-400">
{row.remark || "-"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,582 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Inbox, Search, RotateCcw, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
const formatNum = (v: any) => (v == null || v === "" ? "-" : Number(v).toLocaleString());
const GRID_COLUMNS_CONFIG = [
{ key: "item_number", label: "품번" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "standard_price", label: "기준단가" },
{ key: "selling_price", label: "판매가격" },
{ key: "currency_code", label: "통화" },
{ key: "status", label: "상태" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [inputKeyword, setInputKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-subcontractor-item", ITEM_TABLE, GRID_COLUMNS_CONFIG);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" },
size: { width: "w-[90px]", render: (v) => v || "-" },
unit: { width: "w-[60px]", render: (v) => v || "-" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
selling_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]", render: (v) => v || "-" },
status: { width: "w-[60px]", render: (v) => v || "-" },
};
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
...colProps[col.key],
}));
}, [ts.visibleColumns]);
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = [];
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchKeyword, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
const handleSearch = () => setSearchKeyword(inputKeyword);
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 브레드크럼 */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span></span>
<span className="text-muted-foreground/50">/</span>
<span className="text-foreground font-medium"></span>
</div>
{/* 검색 바 */}
<div className="flex items-center gap-2 px-4 py-3 bg-card border rounded-lg shrink-0">
<span className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap"></span>
<Input
className="h-9 w-[200px]"
placeholder="품명 검색"
value={inputKeyword}
onChange={(e) => setInputKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" size="sm" className="h-9" onClick={() => { setInputKeyword(""); setSearchKeyword(""); }}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" className="h-9" onClick={handleSearch}>
<Search className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="ml-auto flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{itemCount}</span>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<EDataTable
columns={mainTableColumns}
data={ts.groupData(items)}
loading={itemLoading}
emptyMessage="등록된 외주품목이 없어요"
selectedId={selectedItemId}
onSelect={(id) => setSelectedItemId(id)}
onRowDoubleClick={() => openEditItem()}
showPagination={true}
draggableColumns={false}
columnOrderKey="c16-subcontractor-item-main"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/50 shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
<Inbox className="w-12 h-12 text-muted-foreground/40" />
<div>
<p className="text-sm font-semibold text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
</div>
) : subcontractorLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : subcontractorItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Inbox className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex-1 overflow-auto">
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] 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-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</thead>
<TableBody>
{subcontractorItems.map((item, idx) => (
<TableRow key={item.id || idx}>
<TableCell className="text-[13px] font-mono">{item.subcontractor_code || "-"}</TableCell>
<TableCell className="text-sm">{item.subcontractor_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.base_price)}</TableCell>
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.calculated_price)}</TableCell>
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<Dialog open={editItemOpen} onOpenChange={setEditItemOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>{editItemForm.item_number || ""} {editItemForm.item_name || ""}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="flex-1 overflow-auto border rounded-lg">
<Table noWrapper>
<thead className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[130px] 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-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</thead>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-[13px]">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-[13px]">{s.division}</TableCell>
<TableCell className="text-[13px]">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{/* 테이블 설정 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,994 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitBranch, Loader2, PackagePlus, Pencil, Plus, Save, Search, Star, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
createRoutingVersion,
getProcessList,
getRegisteredItems,
getRoutingDetails,
getRoutingVersions,
registerItemsBatch,
saveRoutingDetails,
searchAllItems,
unregisterItem,
type ItemForRouting,
type ProcessMaster,
type RegisteredItem,
type RoutingDetail,
type RoutingVersion,
} from "@/lib/api/processInfo";
import { cn } from "@/lib/utils";
function normalizeDefaultFlag(v: RoutingVersion): boolean {
const raw = v.is_default as unknown;
if (typeof raw === "boolean") return raw;
if (raw === "t" || raw === true || raw === "Y" || raw === "true") return true;
return false;
}
function sortDetailsBySeq(rows: RoutingDetail[]): RoutingDetail[] {
return [...rows].sort((a, b) => {
const na = parseInt(String(a.seq_no), 10) || 0;
const nb = parseInt(String(b.seq_no), 10) || 0;
return na - nb;
});
}
export function ItemRoutingTab() {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [items, setItems] = useState<RegisteredItem[]>([]);
const [itemsLoading, setItemsLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<RegisteredItem | null>(null);
const [versions, setVersions] = useState<RoutingVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [details, setDetails] = useState<RoutingDetail[]>([]);
const [detailsLoading, setDetailsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedDetailIds, setSelectedDetailIds] = useState<Set<string>>(new Set());
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [processesLoading, setProcessesLoading] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [versionName, setVersionName] = useState("");
const [versionDescription, setVersionDescription] = useState("");
const [versionIsDefault, setVersionIsDefault] = useState(false);
const [versionSubmitting, setVersionSubmitting] = useState(false);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailDialogMode, setDetailDialogMode] = useState<"add" | "edit">("add");
const [editingDetailId, setEditingDetailId] = useState<string | null>(null);
const [formProcessCode, setFormProcessCode] = useState("");
const [formSeqNo, setFormSeqNo] = useState("");
const [formRequired, setFormRequired] = useState("Y");
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [registerSearch, setRegisterSearch] = useState("");
const [registerSearchDebounced, setRegisterSearchDebounced] = useState("");
const [registerItems, setRegisterItems] = useState<ItemForRouting[]>([]);
const [registerLoading, setRegisterLoading] = useState(false);
const [registerSelectedIds, setRegisterSelectedIds] = useState<Set<string>>(new Set());
const [registerSubmitting, setRegisterSubmitting] = useState(false);
useEffect(() => {
const t = window.setTimeout(() => setDebouncedSearch(searchInput.trim()), 300);
return () => window.clearTimeout(t);
}, [searchInput]);
useEffect(() => {
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
return () => window.clearTimeout(t);
}, [registerSearch]);
const loadItems = useCallback(async (search: string) => {
setItemsLoading(true);
try {
const res = await getRegisteredItems(search || undefined);
if (res.success && res.data) {
setItems(res.data);
} else {
toast.error(res.message || "품목 목록을 불러오지 못했어요");
setItems([]);
}
} finally {
setItemsLoading(false);
}
}, []);
useEffect(() => {
void loadItems(debouncedSearch);
}, [debouncedSearch, loadItems]);
const loadProcesses = useCallback(async () => {
setProcessesLoading(true);
try {
const procRes = await getProcessList({ useYn: "Y" });
if (!procRes.success || !procRes.data) {
toast.error(procRes.message || "공정 목록을 불러오지 못했어요");
setProcesses([]);
return;
}
setProcesses(procRes.data);
} finally {
setProcessesLoading(false);
}
}, []);
useEffect(() => {
void loadProcesses();
}, [loadProcesses]);
const loadRegisterItems = useCallback(async (search: string) => {
setRegisterLoading(true);
try {
const res = await searchAllItems(search || undefined);
if (res.success && res.data) {
setRegisterItems(res.data);
} else {
setRegisterItems([]);
}
} finally {
setRegisterLoading(false);
}
}, []);
useEffect(() => {
if (!registerDialogOpen) return;
void loadRegisterItems(registerSearchDebounced);
}, [registerSearchDebounced, registerDialogOpen, loadRegisterItems]);
const registeredItemCodes = useMemo(() => new Set(items.map((i) => i.item_code)), [items]);
const handleRegisterItems = async () => {
if (registerSelectedIds.size === 0) {
toast.error("등록할 품목을 선택해주세요");
return;
}
setRegisterSubmitting(true);
try {
const selected = registerItems.filter((ri) => registerSelectedIds.has(ri.id));
const batchItems = selected.map((item) => ({
itemId: item.id,
itemCode: item.item_number,
}));
const res = await registerItemsBatch(batchItems);
if (res.success) {
toast.success(`${batchItems.length}건 품목이 등록되었어요`);
setRegisterDialogOpen(false);
setRegisterSearch("");
setRegisterSelectedIds(new Set());
void loadItems(debouncedSearch);
} else {
toast.error(res.message || "품목 등록에 실패했어요");
}
} finally {
setRegisterSubmitting(false);
}
};
const loadVersions = useCallback(async (item: RegisteredItem, preferVersionId?: string) => {
setVersionsLoading(true);
setVersions([]);
setSelectedVersionId(null);
setDetails([]);
setSelectedDetailIds(new Set());
try {
const res = await getRoutingVersions(item.item_code);
if (!res.success || !res.data) {
toast.error(res.message || "라우팅 버전을 불러오지 못했어요");
return;
}
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
setVersions(list);
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
const def = list.find((v) => v.is_default);
const pick = preferred ?? def ?? list[0];
if (pick) setSelectedVersionId(pick.id);
} finally {
setVersionsLoading(false);
}
}, []);
useEffect(() => {
if (!selectedItem) return;
void loadVersions(selectedItem);
}, [selectedItem, loadVersions]);
const loadDetails = useCallback(async (versionId: string) => {
setDetailsLoading(true);
setSelectedDetailIds(new Set());
try {
const res = await getRoutingDetails(versionId);
if (res.success && res.data) {
setDetails(sortDetailsBySeq(res.data));
} else {
toast.error(res.message || "라우팅 공정을 불러오지 못했어요");
setDetails([]);
}
} finally {
setDetailsLoading(false);
}
}, []);
useEffect(() => {
if (!selectedVersionId) {
setDetails([]);
return;
}
void loadDetails(selectedVersionId);
}, [selectedVersionId, loadDetails]);
const showOutsourceField = formWorkType === "외주" || formWorkType === "선택가능";
const openAddDetailDialog = () => {
if (!selectedVersionId) {
toast.error("먼저 라우팅 버전을 선택해주세요");
return;
}
setDetailDialogMode("add");
setEditingDetailId(null);
setFormProcessCode("");
const nextSeq = details.length === 0 ? 1 : Math.max(...details.map((d) => parseInt(String(d.seq_no), 10) || 0)) + 1;
setFormSeqNo(String(nextSeq));
setFormRequired("Y");
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setDetailDialogOpen(true);
};
const openEditDetailDialog = () => {
if (!selectedVersionId) {
toast.error("먼저 라우팅 버전을 선택해주세요");
return;
}
if (selectedDetailIds.size !== 1) {
toast.error("수정할 공정 한 건만 선택해주세요");
return;
}
const id = [...selectedDetailIds][0];
const row = details.find((d) => d.id === id);
if (!row) {
toast.error("선택한 공정을 찾을 수 없어요");
return;
}
setDetailDialogMode("edit");
setEditingDetailId(row.id);
setFormProcessCode(row.process_code);
setFormSeqNo(String(row.seq_no));
setFormRequired(row.is_required === "N" ? "N" : "Y");
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
setDetailDialogOpen(true);
};
const submitDetailForm = () => {
if (!selectedVersionId) return;
if (!formProcessCode) {
toast.error("공정을 선택해주세요");
return;
}
const seq = parseInt(formSeqNo, 10);
if (Number.isNaN(seq) || seq < 1) {
toast.error("올바른 순번을 입력해주세요");
return;
}
const st = formStandardTime.trim();
if (st !== "" && Number.isNaN(Number(st))) {
toast.error("표준작업시간은 숫자로 입력해주세요");
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
setDetailSubmitting(true);
try {
if (detailDialogMode === "add") {
const newRow: RoutingDetail = {
id: `new-${crypto.randomUUID()}`,
routing_version_id: selectedVersionId,
seq_no: String(seq),
process_code: formProcessCode,
process_name: proc?.process_name,
is_required: formRequired,
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
} else if (editingDetailId) {
setDetails((prev) =>
sortDetailsBySeq(
prev.map((d) =>
d.id === editingDetailId
? {
...d,
seq_no: String(seq),
process_code: formProcessCode,
process_name: proc?.process_name ?? d.process_name,
is_required: formRequired,
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
}
: d,
),
),
);
toast.success("공정이 수정되었어요. 저장을 눌러 반영해주세요");
}
setDetailDialogOpen(false);
} finally {
setDetailSubmitting(false);
}
};
const deleteSelectedDetails = () => {
if (selectedDetailIds.size === 0) {
toast.error("삭제할 공정을 선택해주세요");
return;
}
setDetails((prev) => prev.filter((d) => !selectedDetailIds.has(d.id)));
setSelectedDetailIds(new Set());
toast.success("선택한 공정이 제거되었어요. 저장을 눌러 반영해주세요");
};
const persistDetails = async () => {
if (!selectedVersionId) {
toast.error("저장할 버전이 없어요");
return;
}
const payload = details.map((d) => ({
seq_no: String(d.seq_no),
process_code: d.process_code,
is_required: d.is_required || "Y",
is_fixed_order: d.is_fixed_order || "Y",
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
}));
setSaving(true);
try {
const res = await saveRoutingDetails(selectedVersionId, payload);
if (res.success) {
toast.success("저장되었어요");
await loadDetails(selectedVersionId);
} else {
toast.error(res.message || "저장에 실패했어요");
}
} finally {
setSaving(false);
}
};
const submitNewVersion = async () => {
if (!selectedItem) return;
const name = versionName.trim();
if (!name) {
toast.error("버전명을 입력해주세요");
return;
}
setVersionSubmitting(true);
try {
const res = await createRoutingVersion({
item_code: selectedItem.item_code,
version_name: name,
description: versionDescription.trim() || undefined,
is_default: versionIsDefault || undefined,
});
if (res.success && res.data) {
toast.success("버전이 추가되었어요");
setVersionDialogOpen(false);
setVersionName("");
setVersionDescription("");
setVersionIsDefault(false);
const created = res.data as RoutingVersion;
await loadVersions(selectedItem, created?.id);
} else {
toast.error(res.message || "버전 추가에 실패했어요");
}
} finally {
setVersionSubmitting(false);
}
};
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: d.outsource_supplier || "—",
})),
[details],
);
return (
<div className="flex h-full flex-col">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* 좌측: 등록 품목 */}
<ResizablePanel defaultSize={30} minSize={18}>
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
<PackagePlus className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">
{items.length}
</Badge>
<div className="ml-auto">
<Button
size="sm"
variant="ghost"
onClick={() => {
setRegisterSearch("");
setRegisterSelectedIds(new Set());
setRegisterItems([]);
setRegisterDialogOpen(true);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="border-b px-3 py-2">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="품목코드 / 품목명 검색"
className="h-8 pl-8 text-xs"
aria-label="품목 검색"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
{itemsLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<p className="text-sm text-muted-foreground"> </p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setRegisterSearch("");
setRegisterSelectedIds(new Set());
setRegisterItems([]);
setRegisterDialogOpen(true);
}}
>
<PackagePlus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="space-y-2">
{items.map((item) => {
const active = selectedItem?.id === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItem(item)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
"border-border bg-card hover:bg-muted/30",
active && "border-primary ring-2 ring-ring/30",
)}
>
<p className="text-sm font-medium text-foreground">{item.item_code}</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{item.item_name}</p>
</button>
);
})}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 버전 + 라우팅 상세 */}
<ResizablePanel defaultSize={70} minSize={35}>
<div className="flex h-full flex-col">
{!selectedItem ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<GitBranch className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs"> </p>
</div>
) : (
<>
{/* 패널 헤더 */}
<div className="flex items-center gap-3 border-b bg-muted/30 px-4 py-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{selectedItem.item_name}</p>
<p className="text-xs text-muted-foreground"> {selectedItem.item_code}</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="destructive"
size="sm"
onClick={async () => {
if (!selectedItem.registered_id) return;
const res = await unregisterItem(selectedItem.registered_id);
if (res.success) {
toast.success("품목 등록이 해제되었어요");
setSelectedItem(null);
void loadItems(debouncedSearch);
} else {
toast.error(res.message || "등록 해제에 실패했어요");
}
}}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
size="sm"
onClick={() => {
setVersionName("");
setVersionDescription("");
setVersionIsDefault(false);
setVersionDialogOpen(true);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 버전 pills */}
<div className="border-b bg-muted/30 px-4 py-2">
{versionsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
...
</div>
) : versions.length === 0 ? (
<p className="text-sm text-muted-foreground">
. .
</p>
) : (
<div className="flex flex-wrap items-center gap-2">
{versions.map((v) => {
const selected = v.id === selectedVersionId;
const def = normalizeDefaultFlag(v);
return (
<button
key={v.id}
type="button"
onClick={() => setSelectedVersionId(v.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
{def && (
<Star
className={cn(
"h-3.5 w-3.5",
selected ? "fill-primary-foreground" : "fill-warning text-warning",
)}
aria-label="기본 버전"
/>
)}
{v.version_name}
</button>
);
})}
</div>
)}
</div>
{/* 액션 바 */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">
{details.length}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={openAddDetailDialog}
disabled={!selectedVersionId || detailsLoading}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={openEditDetailDialog}
disabled={!selectedVersionId || detailsLoading}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={deleteSelectedDetails}
disabled={!selectedVersionId || detailsLoading}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
size="sm"
onClick={() => void persistDetails()}
disabled={!selectedVersionId || detailsLoading || saving}
>
{saving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{/* 라우팅 공정 테이블 */}
<div className="min-h-0 flex-1 overflow-auto">
{!selectedVersionId ? (
<p className="py-8 text-center text-sm text-muted-foreground"> </p>
) : (
<EDataTable
columns={[
{ key: "seq_no", label: "순번", width: "w-[80px]", align: "center" as const },
{ key: "process_display", label: "공정명" },
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
{ key: "outsource_display", label: "외주업체" },
] as EDataTableColumn[]}
data={detailsGridData}
rowKey={(row) => row.id}
loading={detailsLoading}
emptyMessage="등록된 공정이 없어요"
showCheckbox
checkedIds={Array.from(selectedDetailIds)}
onCheckedChange={(ids) => setSelectedDetailIds(new Set(ids))}
showPagination={false}
draggableColumns={false}
/>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 버전 추가 모달 */}
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="예: Rev.A"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={versionDescription}
onChange={(e) => setVersionDescription(e.target.value)}
placeholder="선택 입력"
className="h-9"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="versionDefault"
checked={versionIsDefault}
onCheckedChange={(c) => setVersionIsDefault(c === true)}
/>
<Label htmlFor="versionDefault" className="text-sm font-normal">
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVersionDialogOpen(false)} disabled={versionSubmitting}>
</Button>
<Button onClick={() => void submitNewVersion()} disabled={versionSubmitting}>
{versionSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 공정 추가/수정 모달 */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{detailDialogMode === "add" ? "공정 추가" : "공정 수정"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select value={formProcessCode} onValueChange={setFormProcessCode}>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder={processesLoading ? "불러오는 중..." : "공정 선택"} />
</SelectTrigger>
<SelectContent>
{processes.map((p) => (
<SelectItem key={p.id} value={p.process_code}>
{p.process_name} ({p.process_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
type="number"
min={1}
value={formSeqNo}
onChange={(e) => setFormSeqNo(e.target.value)}
className="h-9"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formRequired} onValueChange={setFormRequired}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">Y</SelectItem>
<SelectItem value="N">N</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formFixedOrder} onValueChange={setFormFixedOrder}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">Y</SelectItem>
<SelectItem value="N">N</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formWorkType} onValueChange={setFormWorkType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="내부"></SelectItem>
<SelectItem value="외주"></SelectItem>
<SelectItem value="선택가능"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>
<Input
type="number"
min={0}
step={1}
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
className="h-9"
/>
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formOutsource}
onChange={(e) => setFormOutsource(e.target.value)}
placeholder="외주 업체명"
className="h-9"
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDetailDialogOpen(false)} disabled={detailSubmitting}>
</Button>
<Button onClick={submitDetailForm} disabled={detailSubmitting}>
{detailSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 등록 모달 */}
<Dialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={registerSearch}
onChange={(e) => setRegisterSearch(e.target.value)}
placeholder="품목코드 / 품목명으로 검색"
className="h-9 pl-8 text-xs"
aria-label="품목 검색"
/>
</div>
<div className="max-h-[400px] overflow-auto rounded-md border">
{registerLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
...
</div>
) : registerItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{registerSearchDebounced ? "검색 결과가 없어요" : "품목을 검색해주세요"}
</p>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-10 text-center text-muted-foreground">
<Checkbox
checked={
registerItems.filter((ri) => !registeredItemCodes.has(ri.item_number)).length > 0 &&
registerItems
.filter((ri) => !registeredItemCodes.has(ri.item_number))
.every((ri) => registerSelectedIds.has(ri.id))
}
onCheckedChange={(checked) => {
const next = new Set(registerSelectedIds);
const available = registerItems.filter((ri) => !registeredItemCodes.has(ri.item_number));
if (checked) {
available.forEach((ri) => next.add(ri.id));
} else {
available.forEach((ri) => next.delete(ri.id));
}
setRegisterSelectedIds(next);
}}
/>
</TableHead>
<TableHead className="text-xs text-muted-foreground"></TableHead>
<TableHead className="text-xs text-muted-foreground"></TableHead>
<TableHead className="text-xs text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registerItems.map((ri) => {
const alreadyRegistered = registeredItemCodes.has(ri.item_number);
return (
<TableRow key={ri.id} className={cn("hover:bg-muted/30", alreadyRegistered && "opacity-50")}>
<TableCell className="text-center">
<Checkbox
checked={registerSelectedIds.has(ri.id)}
disabled={alreadyRegistered}
onCheckedChange={(checked) => {
const next = new Set(registerSelectedIds);
if (checked) next.add(ri.id);
else next.delete(ri.id);
setRegisterSelectedIds(next);
}}
/>
</TableCell>
<TableCell className="text-xs">{ri.item_number}</TableCell>
<TableCell className="text-xs">{ri.item_name}</TableCell>
<TableCell className="text-xs">
{alreadyRegistered ? (
<span className="text-muted-foreground"></span>
) : (
<span className="text-primary"></span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
{registerSelectedIds.size > 0 ? (
<p className="text-xs text-muted-foreground">{registerSelectedIds.size} </p>
) : (
<div />
)}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
</Button>
<Button
onClick={() => void handleRegisterItems()}
disabled={registerSubmitting || registerSelectedIds.size === 0}
>
{registerSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
({registerSelectedIds.size})
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,664 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Loader2,
Settings,
Plus,
Pencil,
Trash2,
Search,
RotateCcw,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
getProcessList,
createProcess,
updateProcess,
deleteProcesses,
getProcessEquipments,
addProcessEquipment,
removeProcessEquipment,
getEquipmentList,
type ProcessMaster,
type ProcessEquipment,
type Equipment,
} from "@/lib/api/processInfo"; // API: /process-info/*
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const ALL_VALUE = "__all__";
export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
const [equipmentPick, setEquipmentPick] = useState<string>("");
const [addingEquipment, setAddingEquipment] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [savingForm, setSavingForm] = useState(false);
const [formProcessCode, setFormProcessCode] = useState("");
const [formProcessName, setFormProcessName] = useState("");
const [formProcessType, setFormProcessType] = useState<string>("");
const [formStandardTime, setFormStandardTime] = useState("");
const [formWorkerCount, setFormWorkerCount] = useState("");
const [formUseYn, setFormUseYn] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const processTypeMap = useMemo(() => {
const m = new Map<string, string>();
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
return m;
}, [processTypeOptions]);
const getProcessTypeLabel = useCallback(
(code: string) => processTypeMap.get(code) ?? code,
[processTypeMap]
);
const loadProcesses = useCallback(async () => {
setLoadingList(true);
try {
const res = await getProcessList({
processCode: filterCode.trim() || undefined,
processName: filterName.trim() || undefined,
processType: filterType === ALL_VALUE ? undefined : filterType,
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
});
if (!res.success) {
toast.error(res.message || "공정 목록을 불러오지 못했어요");
return;
}
setProcesses(res.data ?? []);
} finally {
setLoadingList(false);
}
}, [filterCode, filterName, filterType, filterUseYn]);
const loadInitial = useCallback(async () => {
setLoadingInitial(true);
try {
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
if (!procRes.success) {
toast.error(procRes.message || "공정 목록을 불러오지 못했어요");
} else {
setProcesses(procRes.data ?? []);
}
if (!eqRes.success) {
toast.error(eqRes.message || "설비 목록을 불러오지 못했어요");
} else {
setEquipmentMaster(eqRes.data ?? []);
}
const ptRes = await getCategoryValues("process_mng", "process_type");
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
}, []);
useEffect(() => {
void loadInitial();
}, [loadInitial]);
useEffect(() => {
setSelectedProcess((prev) => {
if (!prev) return prev;
if (!processes.some((p) => p.id === prev.id)) return null;
return prev;
});
}, [processes]);
useEffect(() => {
setEquipmentPick("");
}, [selectedProcess?.id]);
useEffect(() => {
if (!selectedProcess) {
setProcessEquipments([]);
return;
}
let cancelled = false;
setLoadingEquipments(true);
void (async () => {
const res = await getProcessEquipments(selectedProcess.process_code);
if (cancelled) return;
if (!res.success) {
toast.error(res.message || "공정 설비를 불러오지 못했어요");
setProcessEquipments([]);
} else {
setProcessEquipments(res.data ?? []);
}
setLoadingEquipments(false);
})();
return () => {
cancelled = true;
};
}, [selectedProcess?.process_code]);
const handleResetFilters = () => {
setFilterCode("");
setFilterName("");
setFilterType(ALL_VALUE);
setFilterUseYn(ALL_VALUE);
};
const handleSearch = () => {
void loadProcesses();
};
const openAdd = () => {
setFormMode("add");
setEditingId(null);
setFormProcessCode("");
setFormProcessName("");
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
setFormStandardTime("");
setFormWorkerCount("");
setFormUseYn("Y");
setFormOpen(true);
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setFormOpen(true);
};
const submitForm = async () => {
if (!formProcessName.trim()) {
toast.error("공정명을 입력해주세요");
return;
}
setSavingForm(true);
try {
if (formMode === "add") {
const res = await createProcess({
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "등록에 실패했어요");
return;
}
toast.success("공정이 등록되었어요");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
setSelectedIds(new Set());
} else if (editingId) {
const res = await updateProcess(editingId, {
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "수정에 실패했어요");
return;
}
toast.success("공정이 수정되었어요");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
}
} finally {
setSavingForm(false);
}
};
const openDelete = () => {
if (selectedIds.size === 0) {
toast.message("삭제할 공정을 체크박스로 선택해주세요");
return;
}
setDeleteOpen(true);
};
const confirmDelete = async () => {
const ids = Array.from(selectedIds);
setDeleting(true);
try {
const res = await deleteProcesses(ids);
if (!res.success) {
toast.error(res.message || "삭제에 실패했어요");
return;
}
toast.success(`${ids.length}건 삭제되었어요`);
setDeleteOpen(false);
setSelectedIds(new Set());
if (selectedProcess && ids.includes(selectedProcess.id)) {
setSelectedProcess(null);
}
await loadProcesses();
} finally {
setDeleting(false);
}
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
if (!selectedProcess) return;
if (!equipmentPick) {
toast.message("추가할 설비를 선택해주세요");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했어요");
return;
}
toast.success("설비가 등록되었어요");
setEquipmentPick("");
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
} finally {
setAddingEquipment(false);
}
};
const handleRemoveEquipment = async (row: ProcessEquipment) => {
const res = await removeProcessEquipment(row.id);
if (!res.success) {
toast.error(res.message || "설비 제거에 실패했어요");
return;
}
toast.success("설비가 제거되었어요");
if (selectedProcess) {
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
}
};
const listBusy = loadingInitial || loadingList;
// 표시용 데이터
const processGridData = useMemo(
() =>
processes.map((p) => ({
...p,
process_type_display: getProcessTypeLabel(p.process_type),
use_yn_display: p.use_yn === "Y" ? "사용" : "미사용",
})),
[processes, getProcessTypeLabel]
);
return (
<div className="flex h-full flex-col">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* 좌측: 공정 목록 */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">
{processes.length}
</Badge>
</div>
{/* 필터 바 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<Input value={filterCode} onChange={(e) => setFilterCode(e.target.value)} placeholder="공정코드" className="h-8 w-[110px] text-xs" />
<Input value={filterName} onChange={(e) => setFilterName(e.target.value)} placeholder="공정명" className="h-8 w-[130px] text-xs" />
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[110px] text-xs">
<SelectValue placeholder="유형 전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}></SelectItem>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue placeholder="사용 전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
<div className="flex-1" />
{listBusy && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleSearch} disabled={listBusy}>
<Search className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs text-muted-foreground" onClick={handleResetFilters}>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
{/* 액션 바 */}
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
<Button size="sm" onClick={openAdd}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="secondary" size="sm" onClick={openEdit}>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="destructive" size="sm" onClick={openDelete}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 공정 목록 테이블 */}
<div className="min-h-0 flex-1 overflow-auto">
<EDataTable
columns={[
{ key: "process_code", label: "공정코드", width: "w-[130px]", render: (val: any) => <span className="font-mono text-xs">{val}</span> },
{ key: "process_name", label: "공정명" },
{ key: "process_type_display", label: "공정유형", width: "w-[120px]" },
{ key: "standard_time", label: "표준시간(분)", width: "w-[110px]", align: "right" as const },
{ key: "worker_count", label: "작업인원", width: "w-[90px]", align: "right" as const },
{ key: "use_yn_display", label: "사용여부", width: "w-[90px]", align: "center" as const },
] as EDataTableColumn[]}
data={processGridData}
rowKey={(row) => row.id}
loading={listBusy}
emptyMessage="조회된 공정이 없어요"
selectedId={selectedProcess?.id ?? null}
onSelect={(id) => {
const proc = processes.find((p) => p.id === id);
setSelectedProcess(proc || null);
}}
onRowClick={(row) => {
const proc = processes.find((p) => p.id === row.id);
setSelectedProcess(proc || null);
}}
showCheckbox
checkedIds={Array.from(selectedIds)}
onCheckedChange={(ids) => setSelectedIds(new Set(ids))}
showPagination={false}
draggableColumns={false}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 공정별 설비 */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
<Wrench className="h-4 w-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold"> </p>
{selectedProcess ? (
<p className="truncate text-xs text-muted-foreground">
{selectedProcess.process_name} ({selectedProcess.process_code})
</p>
) : (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
</div>
{!selectedProcess ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<Settings className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs">
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> </Label>
<Select
key={selectedProcess.id}
value={equipmentPick || undefined}
onValueChange={setEquipmentPick}
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder="설비를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem key={eq.id} value={eq.equipment_code}>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
onClick={() => void handleAddEquipment()}
disabled={addingEquipment || !equipmentPick}
>
{addingEquipment ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{loadingEquipments ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<p className="mt-2 text-sm"> ...</p>
</div>
) : processEquipments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
. .
</p>
) : (
<ul className="space-y-2">
{processEquipments.map((pe) => (
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 공정 등록/수정 모달 */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{formMode === "add" ? "공정 추가" : "공정 수정"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={formProcessName}
onChange={(e) => setFormProcessName(e.target.value)}
placeholder="공정명을 입력해주세요"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formProcessType} onValueChange={setFormProcessType}>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-form-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">()</Label>
<Input
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
inputMode="numeric"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={formWorkerCount}
onChange={(e) => setFormWorkerCount(e.target.value)}
placeholder="0"
inputMode="numeric"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formUseYn} onValueChange={setFormUseYn}>
<SelectTrigger className="h-9" size="sm">
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={savingForm}></Button>
<Button onClick={() => void submitForm()} disabled={savingForm}>
{savingForm && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{selectedIds.size} . - . .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}></Button>
<Button variant="destructive" onClick={() => void confirmDelete()} disabled={deleting}>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
export function ProcessWorkStandardTab() {
return (
<div className="h-[calc(100vh-12rem)]">
<ProcessWorkStandardComponent
config={{
itemListMode: "registered",
screenCode: "screen_1599",
leftPanelTitle: "등록 품목 및 공정",
}}
/>
</div>
);
}
@@ -0,0 +1,223 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Settings,
GitBranch,
ClipboardList,
ChevronRight,
Factory,
Keyboard,
Plus,
Pencil,
Trash2,
List,
Settings2,
} from "lucide-react";
import { ProcessMasterTab } from "./ProcessMasterTab";
import { ItemRoutingTab } from "./ItemRoutingTab";
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
const GRID_COLUMNS = [
{ key: "process_code", label: "공정코드" },
{ key: "process_name", label: "공정명" },
{ key: "process_type", label: "공정유형" },
{ key: "standard_time", label: "표준시간(분)" },
{ key: "worker_count", label: "작업인원" },
{ key: "use_yn", label: "사용여부" },
];
const TAB_META = [
{
value: "process",
label: "공정 마스터",
shortLabel: "공정",
description: "공정코드/유형/표준시간/사용설비 관리",
detailDesc: "공정코드, 공정유형, 표준시간을 등록하고 사용 설비를 매핑합니다.",
icon: Settings,
color: "text-blue-500",
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
shortcut: "1",
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
},
{
value: "routing",
label: "품목별 라우팅",
shortLabel: "라우팅",
description: "품목 라우팅 버전 및 공정 순서 관리",
detailDesc: "품목별 생산 라우팅 버전을 관리하고 공정 순서 및 소요시간을 설정합니다.",
icon: GitBranch,
color: "text-emerald-500",
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
shortcut: "2",
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
},
{
value: "workstandard",
label: "공정 작업기준",
shortLabel: "작업기준",
description: "공정별 작업기준서 및 작업 표준 관리",
detailDesc: "공정별 작업기준서를 등록하고 작업 표준을 문서화하여 품질을 관리합니다.",
icon: ClipboardList,
color: "text-violet-500",
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
shortcut: "3",
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
},
] as const;
type TabValue = (typeof TAB_META)[number]["value"];
const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
export default function ProcessInfoPage() {
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
const [activeTab, setActiveTab] = useState<TabValue>("process");
const [showShortcutHint, setShowShortcutHint] = useState(false);
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
const handleTabChange = useCallback((value: string) => {
setActiveTab(value as TabValue);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.altKey) return;
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
if (tabByShortcut) {
e.preventDefault();
setActiveTab(tabByShortcut.value);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
{/* 페이지 헤더 */}
<div className="shrink-0 border-b bg-background px-6 py-3">
<nav
className="mb-1 flex items-center gap-1 text-xs text-muted-foreground"
aria-label="breadcrumb"
>
<Factory className="h-3 w-3" />
<span></span>
<ChevronRight className="h-3 w-3 opacity-50" />
<span className="font-medium text-foreground"></span>
</nav>
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-2">
<h1 className="text-base font-semibold text-foreground"></h1>
<span className="text-xs text-muted-foreground">{activeMeta.description}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setShowShortcutHint((v) => !v)}
aria-label="키보드 단축키 보기"
>
<Keyboard className="h-3 w-3" />
<span></span>
</button>
</div>
</div>
{showShortcutHint && (
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground"> :</span>
{TAB_META.map((t) => (
<span key={t.value} className="flex items-center gap-1">
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
Alt+{t.shortcut}
</kbd>
<span>{t.shortLabel}</span>
</span>
))}
</div>
)}
</div>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex min-h-0 flex-1 flex-col"
>
{/* 탭 네비게이션 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
<TabsTrigger
key={value}
value={value}
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
aria-label={`${label} (Alt+${shortcut})`}
>
<Icon className="h-4 w-4" />
{label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* 탭 설명 배너 */}
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
>
{activeMeta.shortLabel}
</span>
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
</div>
<div className="hidden items-center gap-2 sm:flex">
{activeMeta.actions.map((action, i) => {
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
return (
<span
key={action}
className="flex items-center gap-1 text-xs text-muted-foreground/70"
>
<ActionIcon className="h-3 w-3" />
{action}
</span>
);
})}
</div>
</div>
</div>
{/* 탭 컨텐츠 */}
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
</TabsContent>
<TabsContent value="routing" className="min-h-0 flex-1 overflow-hidden mt-0">
<ItemRoutingTab />
</TabsContent>
<TabsContent value="workstandard" className="min-h-0 flex-1 overflow-hidden mt-0">
<ProcessWorkStandardTab />
</TabsContent>
</Tabs>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
@@ -0,0 +1,843 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck, Inbox, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
// API: /work-instruction/*
import {
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
getRoutingVersions, RoutingVersionData,
} from "@/lib/api/workInstruction";
import { WorkStandardEditModal } from "./WorkStandardEditModal";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const GRID_COLUMNS = [
{ key: "work_instruction_no", label: "작업지시번호" },
{ key: "status", label: "상태" },
{ key: "progress", label: "진행현황" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "qty", label: "수량" },
{ key: "equipment", label: "설비" },
{ key: "routing", label: "라우팅" },
{ key: "work_team", label: "작업조" },
{ key: "worker", label: "작업자" },
{ key: "start_date", label: "시작일" },
{ key: "end_date", label: "완료일" },
{ key: "actions", label: "작업" },
];
type SourceType = "production" | "order" | "item";
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
"일반": { label: "일반", cls: "bg-primary/10 text-primary border-primary/20" },
"긴급": { label: "긴급", cls: "bg-destructive/10 text-destructive border-destructive/20" },
};
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
"대기": { label: "대기", cls: "bg-warning/10 text-warning" },
"진행중": { label: "진행중", cls: "bg-primary/10 text-primary" },
"완료": { label: "완료", cls: "bg-success/10 text-success" },
};
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
interface SelectedItem {
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
sourceType: SourceType; sourceTable: string; sourceId: string | number;
routing?: string; routingOptions?: RoutingVersionData[];
}
export default function WorkInstructionPage() {
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
// 검색
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 1단계: 등록 모달
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
const [regSourceData, setRegSourceData] = useState<any[]>([]);
const [regSourceLoading, setRegSourceLoading] = useState(false);
const [regKeyword, setRegKeyword] = useState("");
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
const [regPage, setRegPage] = useState(1);
const [regPageSize] = useState(20);
const [regTotalCount, setRegTotalCount] = useState(0);
// 2단계: 확인 모달
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
const [confirmWiNo, setConfirmWiNo] = useState("");
const [confirmStatus, setConfirmStatus] = useState("일반");
const [confirmStartDate, setConfirmStartDate] = useState("");
const [confirmEndDate, setConfirmEndDate] = useState("");
const nv = (v: string) => v || "none";
const fromNv = (v: string) => v === "none" ? "" : v;
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
const [confirmWorker, setConfirmWorker] = useState("");
const [saving, setSaving] = useState(false);
// 등록 확인 모달 — 인라인 추가 폼
const [confirmAddQty, setConfirmAddQty] = useState("");
const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false);
// 수정 모달
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editOrder, setEditOrder] = useState<any>(null);
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
const [editStatus, setEditStatus] = useState("일반");
const [editStartDate, setEditStartDate] = useState("");
const [editEndDate, setEditEndDate] = useState("");
const [editEquipmentId, setEditEquipmentId] = useState("");
const [editWorkTeam, setEditWorkTeam] = useState("");
const [editWorker, setEditWorker] = useState("");
const [editRemark, setEditRemark] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [addQty, setAddQty] = useState("");
const [addEquipment, setAddEquipment] = useState("");
const [addWorkTeam, setAddWorkTeam] = useState("");
const [addWorker, setAddWorker] = useState("");
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
// 라우팅 관련 상태
const [confirmRouting, setConfirmRouting] = useState("");
const [confirmRoutingOptions, setConfirmRoutingOptions] = useState<RoutingVersionData[]>([]);
const [editRouting, setEditRouting] = useState("");
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
// 공정작업기준 모달 상태
const [wsModalOpen, setWsModalOpen] = useState(false);
const [wsModalWiNo, setWsModalWiNo] = useState("");
const [wsModalRoutingId, setWsModalRoutingId] = useState("");
const [wsModalRoutingName, setWsModalRoutingName] = useState("");
const [wsModalItemName, setWsModalItemName] = useState("");
const [wsModalItemCode, setWsModalItemCode] = useState("");
useEffect(() => {
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
}, []);
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
for (const f of searchFilters) {
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
const [from, to] = f.value.split("|");
if (from) params.dateFrom = from;
if (to) params.dateTo = to;
} else if (f.columnName === "status" && f.value) {
params.status = f.value;
} else if (f.columnName === "progress" && f.value) {
params.progressStatus = f.value;
} else if (f.columnName === "work_instruction_no" && f.value) {
params.keyword = f.value;
} else if (f.columnName === "item_name" && f.value) {
params.keyword = f.value;
}
}
const r = await getWorkInstructionList(params);
if (r.success) setOrders(r.data || []);
} catch {} finally { setLoading(false); }
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// ─── 1단계 등록 ───
const openRegModal = () => {
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
};
const fetchRegSource = useCallback(async (pageOverride?: number) => {
if (!regSourceType) return;
setRegSourceLoading(true);
try {
const p = pageOverride ?? regPage;
const params: any = { page: p, pageSize: regPageSize };
if (regKeyword.trim()) params.keyword = regKeyword.trim();
let r;
switch (regSourceType) {
case "production": r = await getWIProductionPlanSource(params); break;
case "order": r = await getWISalesOrderSource(params); break;
case "item": r = await getWIItemSource(params); break;
}
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
} catch {} finally { setRegSourceLoading(false); }
}, [regSourceType, regKeyword, regPage, regPageSize]);
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
const applyRegistration = () => {
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
const items: SelectedItem[] = [];
for (const item of regSourceData) {
if (!regCheckedIds.has(getRegId(item))) continue;
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
}
// 동일품목 합산
if (regMergeSameItem) {
const merged = new Map<string, SelectedItem>();
for (const it of items) {
const key = it.itemCode;
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
else { merged.set(key, { ...it }); }
}
setConfirmItems(Array.from(merged.values()));
} else {
setConfirmItems(items);
}
setConfirmWiNo("불러오는 중...");
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
setConfirmRouting(""); setConfirmRoutingOptions([]);
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
// 품목별 라우팅 옵션 로드
const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items;
const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))];
for (const ic of uniqueItemCodes) {
getRoutingVersions("__new__", ic).then(r => {
if (r.success && r.data) {
setConfirmItems(prev => prev.map(it => {
if (it.itemCode !== ic) return it;
const defaultRv = r.data.find(rv => rv.is_default);
return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" };
}));
}
}).catch(() => {});
}
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
};
// 등록 확인 모달 — 인라인 품목 추가
const addConfirmItem = () => {
if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; }
const firstItem = confirmItems[0];
setConfirmItems(prev => [...prev, {
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
qty: Number(confirmAddQty), remark: "",
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
}]);
setConfirmAddQty("");
};
// ─── 2단계 최종 적용 ───
const finalizeRegistration = async () => {
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
setSaving(true);
try {
const payload = {
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
routing: confirmRouting || null,
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
};
// ─── 수정 모달 ───
const openEditModal = (order: any) => {
const wiNo = order.work_instruction_no;
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
setEditOrder(order); setEditStatus(order.status || "일반");
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
const items: SelectedItem[] = relatedDetails.map((d: any) => ({
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
routing: d.detail_routing_version_id || order.routing_version_id || "",
routingOptions: [],
}));
setEditItems(items);
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
setEditRouting(order.routing_version_id || "");
setEditRoutingOptions([]);
// 품목별 라우팅 옵션 로드
const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))];
for (const ic of uniqueItemCodes) {
getRoutingVersions(wiNo, ic).then(r => {
if (r.success && r.data) {
setEditItems(prev => prev.map(it => {
if (it.itemCode !== ic) return it;
const opts = r.data;
const hasRouting = it.routing && opts.some(rv => rv.id === it.routing);
return {
...it,
routingOptions: opts,
routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""),
};
}));
}
}).catch(() => {});
}
setIsEditModalOpen(true);
};
const addEditItem = () => {
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
setEditItems(prev => [...prev, {
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
}]);
setAddQty("");
};
const saveEdit = async () => {
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
setEditSaving(true);
try {
const payload = {
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
routing: editRouting || null,
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
};
const handleDelete = async (wiId: string) => {
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
const r = await deleteWorkInstructions([wiId]);
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
};
const getProgress = (o: any) => {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
const cnt = Number(o.detail_count || 1);
const seq = Number(o.detail_seq || 1);
if (cnt <= 1) return o.work_instruction_no || "-";
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
};
const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => {
if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; }
setWsModalWiNo(wiNo);
setWsModalRoutingId(routingVersionId);
setWsModalRoutingName(routingName);
setWsModalItemName(itemName);
setWsModalItemCode(itemCode);
setWsModalOpen(true);
};
const getWorkerName = (userId: string) => {
if (!userId) return "-";
const emp = employeeOptions.find(e => e.user_id === userId);
return emp ? emp.user_name : userId;
};
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
className?: string; triggerClassName?: string;
}) => (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="이름 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-4 text-center"> </CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
</CommandItem>
{employeeOptions.map(emp => (
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<div className="flex flex-col h-[calc(100vh-4rem)] p-3 gap-3">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName="work_instruction"
filterId="c16-work-instruction"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={new Set(orders.map(o => o.work_instruction_no)).size}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border bg-card rounded-lg flex flex-col">
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4 text-muted-foreground" />
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{new Set(orders.map(o => o.work_instruction_no)).size} ({orders.length})
</span>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={openRegModal}>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 테이블 */}
<EDataTable
columns={[
{ key: "work_instruction_no", label: "작업지시번호", width: "w-[150px]", render: (_v, row) => <span className="font-mono text-[13px] font-medium">{getDisplayNo(row)}</span> },
{ key: "status", label: "상태", width: "w-[70px]", align: "center", render: (v) => {
const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"];
return <Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge>;
}},
{ key: "progress", label: "진행현황", width: "w-[100px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return <span className="text-[10px] text-muted-foreground"></span>;
const pct = getProgress(row);
const pLabel = getProgressLabel(row);
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
return (
<div className="flex flex-col items-center gap-1">
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{pct}%</span>
</div>
);
}},
{ key: "item_name", label: "품목명", render: (_v, row) => row.item_name || row.item_number || "-" },
{ key: "item_spec", label: "규격", width: "w-[100px]" },
{ key: "detail_qty", label: "수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "equipment_name", label: "설비", width: "w-[120px]", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "routing", label: "라우팅", width: "w-[120px]", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return "";
if (row.routing_version_id) {
return (
<button
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
onClick={e => {
e.stopPropagation();
openWorkStandardModal(row.work_instruction_no, row.routing_version_id, row.routing_name || "", row.item_name || row.item_number || "", row.item_number || "");
}}
>
{row.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
</button>
);
}
return <span className="text-muted-foreground">-</span>;
}},
{ key: "work_team", label: "작업조", width: "w-[80px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return null;
return (
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(row)}><Pencil className="w-3 h-3" /> </Button>
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(row.wi_id)}><Trash2 className="w-3 h-3" /></Button>
</div>
);
}},
] as EDataTableColumn[]}
data={ts.groupData(orders)}
rowKey={(row) => `${row.wi_id}-${row.detail_id}`}
loading={loading}
emptyMessage="등록된 작업지시가 없어요"
showPagination
draggableColumns
columnOrderKey="c16-work-instruction"
/>
</div>
</div>
{/* ── 1단계: 등록 모달 ── */}
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><Plus className="w-4 h-4" /> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground"> "작업지시 적용" .</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground whitespace-nowrap"></span>
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent><SelectItem value="production"></SelectItem><SelectItem value="order"></SelectItem><SelectItem value="item"></SelectItem></SelectContent>
</Select>
{regSourceType && (<>
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5"></span>
</Button>
</>)}
<div className="flex-1" />
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
<span className="text-xs font-semibold text-foreground"> </span>
</label>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{!regSourceType ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
<Search className="w-6 h-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground mb-1"> </p>
<p className="text-xs text-muted-foreground/60"> </p>
</div>
) : regSourceLoading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : regSourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
<Inbox className="w-6 h-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground mb-1"> </p>
<p className="text-xs text-muted-foreground/60"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead></TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
{regSourceData.map((item, idx) => {
const id = getRegId(item);
const checked = regCheckedIds.has(id);
return (
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/30", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{regTotalCount > 0 && (
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground"> {regTotalCount} (: {regCheckedIds.size})</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}></Button>
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 2단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> '최종 적용' .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="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-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{confirmItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
<Select
value={nv(item.routing || "")}
onValueChange={v => {
const val = fromNv(v);
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
}}
>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> </Button>
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}></Button>
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
<DialogDescription> / .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
</div>
</div>
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}</span>
</div>
<div className="overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="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-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[180px] 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="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell>
<Select
value={nv(item.routing || "")}
onValueChange={v => {
const val = fromNv(v);
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
}}
>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={!item.routing}
onClick={() => {
if (!editOrder || !item.routing) return;
const rv = (item.routingOptions || []).find((r: RoutingVersionData) => r.id === item.routing);
openWorkStandardModal(
editOrder.work_instruction_no,
item.routing,
rv?.version_name || "",
item.itemName || item.itemCode || "",
item.itemCode || ""
);
}}
>
<ClipboardCheck className="w-3 h-3 mr-1" />
</Button>
</TableCell>
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editItems.length > 0 && (
<div className="px-4 py-2.5 border-t bg-muted/30 flex items-center justify-between">
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-lg font-bold font-mono text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}></Button>
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 공정작업기준 수정 모달 */}
<WorkStandardEditModal
open={wsModalOpen}
onClose={() => setWsModalOpen(false)}
workInstructionNo={wsModalWiNo}
routingVersionId={wsModalRoutingId}
routingName={wsModalRoutingName}
itemName={wsModalItemName}
itemCode={wsModalItemCode}
/>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,733 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
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 {
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
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 GRID_COLUMNS = [
{ key: "item_code", 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: ["수입검사", "입고검사", "수입", "입고"] },
{ key: "outgoing_inspection", label: "출하검사", matchLabels: ["출하검사", "출고검사", "출하", "출고"] },
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
] as const;
type InspectionRow = {
id: string;
inspection_standard_id: string;
inspection_detail: string;
inspection_method: string;
apply_process: string;
acceptance_criteria: string;
is_required: boolean;
};
export default function ItemInspectionInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [form, setForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
/* 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 [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
/* 검사유형별 검사항목 rows */
const [inspectionRows, setInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [collapsedTypes, setCollapsedTypes] = useState<Record<string, boolean>>({});
/* 품목 선택 모달 */
const [itemModalOpen, setItemModalOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [filteredItems, setFilteredItems] = useState<typeof itemOptions>([]);
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
useEffect(() => {
const loadOptions = async () => {
try {
const [itemRes, inspRes, userRes] = await Promise.all([
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }),
]);
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
setItemOptions(items.map((r: any) => ({
code: r.item_number || r.item_code || "",
name: r.item_name || "",
item_type: r.type || r.item_type || "",
unit: r.unit || "",
})));
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
setInspOptions(insps.map((r: any) => ({
code: r.id,
label: r.inspection_criteria || r.inspection_standard || r.id,
detail: r.inspection_item || r.inspection_criteria || "",
method: r.inspection_method || "",
types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [],
})));
// 검사유형 카테고리 값 로드 (코드→라벨 매핑용)
try {
const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`);
const flatCats: { code: string; label: string }[] = [];
const flatten = (arr: any[]) => { for (const v of arr) { flatCats.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flatten(v.children); } };
if (catRes.data?.data?.length) flatten(catRes.data.data);
setInspTypeCatOptions(flatCats);
} catch { /* skip */ }
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
setUserOptions(users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`,
})));
} catch { /* skip */ }
};
loadOptions();
}, []);
/* ═══════════════════ 품목 선택 모달 ═══════════════════ */
const openItemModal = () => {
setItemSearchKeyword("");
setFilteredItems(itemOptions);
setItemModalOpen(true);
};
const handleItemSearch = () => {
const kw = itemSearchKeyword.trim().toLowerCase();
if (!kw) { setFilteredItems(itemOptions); return; }
setFilteredItems(itemOptions.filter(o =>
o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw)
));
};
const selectItem = (item: typeof itemOptions[0]) => {
setForm(p => ({ ...p, item_code: item.code, item_name: item.name }));
setItemModalOpen(false);
};
/* ═══════════════════ 데이터 조회 ═══════════════════ */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setData(rows);
setTotalCount(rows.length);
} catch {
toast.error("품목검사정보 조회에 실패했어요");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchData(); }, [fetchData]);
// item_code별 그룹핑
const groupedData = useMemo(() => {
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
for (const row of data) {
const key = row.item_code || row.id;
if (!map[key]) {
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
}
map[key].rows.push(row);
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
map[key].types.push(row.inspection_type);
}
}
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
}, [inspOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = async (row: any) => {
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 }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
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)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
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 || "",
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({});
}
setModalOpen(true);
};
/* ═══════════════════ 검사항목 행 관리 ═══════════════════ */
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), {
id: crypto.randomUUID(),
inspection_standard_id: "",
inspection_detail: "",
inspection_method: "",
apply_process: "",
acceptance_criteria: "",
is_required: false,
}],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId),
}));
};
const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: opt?.method || "" };
}
return { ...r, [field]: value };
}),
}));
};
/** 검사유형 키에 매칭되는 검사기준만 필터링 */
const getFilteredInspOptions = (typeKey: string) => {
const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey);
if (!typeDef) return inspOptions;
// matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음
const matchCodes = inspTypeCatOptions
.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml)))
.map(cat => cat.code);
if (matchCodes.length === 0) return inspOptions;
return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t)));
};
const toggleCollapse = (typeKey: string) => {
setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] }));
};
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
autoFilter: true,
});
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 })),
});
}
}
// 검사유형별 항목을 개별 행으로 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 || "",
});
} 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 || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
finally { setSaving(false); }
};
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
const ok = await confirm("품목검사정보 삭제", {
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: checkedIds.map(id => ({ id })),
});
toast.success(`${checkedIds.length}건을 삭제했어요`);
setCheckedIds([]);
fetchData();
} catch { toast.error("삭제에 실패했어요"); }
};
/* ═══════════════════ JSX ═══════════════════ */
return (
<div className="flex flex-col gap-3 p-3">
{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>
{/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */}
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) setItemModalOpen(false); setModalOpen(open); }}>
<DialogContent className={cn("max-w-[95vw] max-h-[85vh] overflow-y-auto", itemModalOpen ? "sm:max-w-xl" : "sm:max-w-4xl")}>
{itemModalOpen ? (
<>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품목명으로 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }}
/>
<Button size="sm" className="h-9" onClick={handleItemSearch}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-auto max-h-[50vh]">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow
key={item.code}
className="cursor-pointer hover:bg-primary/5"
onClick={() => selectItem(item)}
>
<TableCell className="text-sm">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemModalOpen(false)}></Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 품목 정보 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5">📦 </h4>
<div className="flex items-end gap-2">
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_code || ""} readOnly placeholder="품목코드" />
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목명" />
</div>
<Button type="button" variant="outline" size="sm" className="h-9 px-3 shrink-0" onClick={openItemModal}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.is_active === false ? "N" : "Y"} onValueChange={(v) => setForm(p => ({ ...p, is_active: v === "Y" }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={form.manager || ""} onValueChange={(v) => setForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>
{userOptions.map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={form.remarks || ""}
onChange={(e) => setForm(p => ({ ...p, remarks: e.target.value }))}
placeholder="비고 사항"
/>
</div>
</div>
{/* 검사유형 선택 */}
<div className="space-y-3">
<h4 className="text-sm font-semibold flex items-center gap-1.5"> </h4>
<div className="flex flex-wrap gap-4">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
checked={!!form[key]}
onCheckedChange={(v) => setForm(p => ({ ...p, [key]: !!v }))}
/>
<Label className="text-sm cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{/* 검사유형별 검사항목 설정 */}
{INSPECTION_TYPES.filter(t => !!form[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-2">
<button
type="button"
className="w-full flex items-center gap-2 py-2 px-3 rounded-md border bg-muted/50 hover:bg-muted text-left"
onClick={() => toggleCollapse(key)}
>
<Badge variant="default" className="text-xs">{label}</Badge>
<span className="text-sm font-medium"> </span>
<ChevronDown className={cn("w-4 h-4 ml-auto transition-transform", collapsedTypes[key] && "-rotate-90")} />
</button>
{!collapsedTypes[key] && (
<div className="space-y-2 pl-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => addInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[170px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={7} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
<SelectContent>
{getFilteredInspOptions(key).map(o => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_detail} readOnly placeholder="검사기준 상세" />
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs bg-muted" value={row.inspection_method} readOnly placeholder="선택" />
</TableCell>
<TableCell className="p-1">
<Select value={row.apply_process || ""} onValueChange={(v) => updateInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="outgoing"></SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
className="h-8 text-xs"
value={row.acceptance_criteria}
onChange={(e) => updateInspRow(key, row.id, "acceptance_criteria", e.target.value)}
placeholder={row.inspection_standard_id ? "합격기준 입력" : "검사기준을 먼저 선택하세요"}
disabled={!row.inspection_standard_id}
/>
</TableCell>
<TableCell className="p-1 text-center">
<Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} />
</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
@@ -0,0 +1,932 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Download,
Plus,
Save,
ClipboardList,
Inbox,
Check,
ChevronsUpDown,
Loader2,
FileSpreadsheet,
Trash2,
Pencil,
FileText,
Wrench,
Settings2,
} from "lucide-react";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
// --- 상수 ---
const TABLE_NAME = "claim_mng";
const GRID_COLUMNS = [
{ key: "claim_no", label: "클레임번호" },
{ key: "claim_date", label: "접수일자" },
{ key: "claim_type", label: "유형" },
{ key: "claim_status", label: "상태" },
{ key: "customer_name", label: "거래처명" },
{ key: "manager_name", label: "담당자" },
{ key: "claim_content", label: "클레임 내용" },
];
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
type ClaimStatus = "접수" | "처리중" | "완료" | "취소";
interface ClaimRow {
id: number;
claim_no: string;
claim_date: string;
claim_type: string;
claim_status: string;
customer_code: string;
customer_name: string;
manager_name: string;
order_no: string;
claim_content: string;
process_content: string;
company_code?: string;
writer?: string;
created_date?: string;
updated_date?: string;
[key: string]: any;
}
interface CustomerOption {
customerCode: string;
customerName: string;
}
interface SalesOrderOption {
orderNo: string;
partnerName: string;
status: string;
}
// Badge variant 매핑 (CSS 변수 기반)
const getClaimStatusVariant = (status: string) => {
switch (status) {
case "접수": return "default" as const;
case "처리중": return "secondary" as const;
case "완료": return "outline" as const;
case "취소": return "destructive" as const;
default: return "secondary" as const;
}
};
const getClaimTypeVariant = (type: string) => {
switch (type) {
case "불량": return "destructive" as const;
case "교환": return "warning" as const;
case "반품": return "default" as const;
case "배송지연": return "secondary" as const;
case "기타": return "outline" as const;
default: return "outline" as const;
}
};
const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", "기타"];
const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"];
export default function ClaimManagementPage() {
useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-claim", TABLE_NAME, GRID_COLUMNS);
const [data, setData] = useState<ClaimRow[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [selectedClaimNo, setSelectedClaimNo] = useState<string | null>(null);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Partial<ClaimRow>>({});
// Combobox 상태
const [customerOpen, setCustomerOpen] = useState(false);
const [orderOpen, setOrderOpen] = useState(false);
// DB 데이터
const [customers, setCustomers] = useState<CustomerOption[]>([]);
const [salesOrders, setSalesOrders] = useState<SalesOrderOption[]>([]);
const [customersLoading, setCustomersLoading] = useState(false);
const [ordersLoading, setOrdersLoading] = useState(false);
// --- 데이터 조회 (서버사이드 필터 + autoFilter 멀티테넌시) ---
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map(f => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "claim_date", order: "desc" },
});
const rows: ClaimRow[] = res.data?.data?.data || res.data?.data?.rows || [];
setData(rows);
setTotalCount(res.data?.data?.total || rows.length);
} catch {
toast.error("클레임 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 거래처 목록 조회 (autoFilter로 멀티테넌시 적용)
const fetchCustomers = useCallback(async (force = false) => {
if (!force && customers.length > 0) return;
setCustomersLoading(true);
try {
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
page: 1,
size: 9999,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows;
if (res.data?.success && Array.isArray(rows)) {
const list: CustomerOption[] = rows.map((row: any) => ({
customerCode: row.customer_code || row.id || "",
customerName: row.customer_name || "",
}));
setCustomers(list);
}
} catch {
/* skip */
} finally {
setCustomersLoading(false);
}
}, [customers.length]);
// 수주 목록 조회 (autoFilter로 멀티테넌시 적용)
const fetchSalesOrders = useCallback(async (force = false) => {
if (!force && salesOrders.length > 0) return;
setOrdersLoading(true);
try {
const res = await apiClient.post("/table-management/tables/sales_order_mng/data", {
page: 1,
size: 9999,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows;
if (res.data?.success && Array.isArray(rows)) {
const seen = new Set<string>();
const list: SalesOrderOption[] = [];
for (const row of rows) {
const orderNo = row.order_no || "";
if (!orderNo || seen.has(orderNo)) continue;
seen.add(orderNo);
list.push({
orderNo,
partnerName: row.partner_id || "",
status: row.status || "",
});
}
setSalesOrders(list);
}
} catch {
/* skip */
} finally {
setOrdersLoading(false);
}
}, [salesOrders.length]);
// 상태별 카운트
const statusCounts = useMemo(() => {
const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 };
data.forEach((claim) => {
if (counts[claim.claim_status as keyof typeof counts] !== undefined) {
counts[claim.claim_status as keyof typeof counts]++;
}
});
return counts;
}, [data]);
// 클레임번호 자동 생성
const generateClaimNo = useCallback(() => {
const year = new Date().getFullYear();
const prefix = `CLM-${year}-`;
const existingNumbers = data
.filter((c) => c.claim_no?.startsWith(prefix))
.map((c) => parseInt(c.claim_no.replace(prefix, ""), 10))
.filter((n) => !isNaN(n));
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
return `${prefix}${String(maxNumber + 1).padStart(3, "0")}`;
}, [data]);
const handleRowClick = (claimNo: string) => {
setSelectedClaimNo(claimNo);
};
const openRegisterModal = () => {
setIsEditMode(false);
setFormData({
claim_no: generateClaimNo(),
claim_date: new Date().toISOString().split("T")[0],
claim_type: undefined,
claim_status: "접수",
customer_code: "",
customer_name: "",
manager_name: "",
order_no: "",
claim_content: "",
process_content: "",
});
setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
};
const openEditModal = (claimNo: string) => {
const claim = data.find((c) => c.claim_no === claimNo);
if (!claim) return;
setIsEditMode(true);
setFormData({ ...claim });
setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
};
const handleFormChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// --- 저장 (table-management API, company_code 자동 주입) ---
const handleSave = async () => {
if (!formData.claim_type || !formData.customer_name || !formData.claim_content) {
toast.error("필수 항목을 모두 입력해주세요. (클레임유형, 거래처명, 클레임내용)");
return;
}
setSaving(true);
try {
const { id, company_code, writer, created_date, updated_date, created_by, updated_by, ...saveFields } = formData as any;
if (isEditMode && id) {
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id },
updatedData: saveFields,
});
toast.success("클레임이 수정되었어요.");
} else {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, saveFields);
toast.success("클레임이 등록되었어요.");
}
setIsModalOpen(false);
fetchData();
} catch (err: any) {
console.error("클레임 저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
// --- 삭제 ---
const handleDelete = async (claimNo: string) => {
const claim = data.find((c) => c.claim_no === claimNo);
if (!claim) return;
const ok = await confirm(`클레임 ${claimNo}을(를) 삭제하시겠습니까?`, {
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: claim.id }],
});
toast.success("클레임이 삭제되었어요.");
if (selectedClaimNo === claimNo) setSelectedClaimNo(null);
fetchData();
} catch (err: any) {
console.error("클레임 삭제 실패:", err);
toast.error(err.response?.data?.message || "삭제에 실패했어요.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (data.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
try {
const exportData = data.map((row) => ({
클레임번호: row.claim_no,
접수일자: row.claim_date,
클레임유형: row.claim_type,
처리상태: row.claim_status,
거래처코드: row.customer_code,
거래처명: row.customer_name,
담당자: row.manager_name,
수주번호: row.order_no,
클레임내용: row.claim_content,
처리내용: row.process_content,
}));
await exportToExcel(exportData, "클레임관리.xlsx", "클레임");
toast.success("엑셀 다운로드 완료");
} catch (err) {
console.error("엑셀 다운로드 실패:", err);
toast.error("엑셀 다운로드에 실패했어요.");
}
};
const selectedClaim = useMemo(
() => data.find((c) => c.claim_no === selectedClaimNo),
[data, selectedClaimNo]
);
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* 검색 필터 (DynamicSearchFilter) */}
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-claim"
onFilterChange={setSearchFilters}
dataCount={data.length}
externalFilterConfig={ts.filterConfig}
/>
{/* ───── 메인 분할 레이아웃 ───── */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* ── 좌측: 클레임 목록 (65%) ── */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex flex-col h-full bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{data.length}
</span>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 테이블 */}
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn<ClaimRow> => ({
key: col.key,
label: col.label,
align: col.key === "claim_type" || col.key === "claim_status" ? "center" : undefined,
render: col.key === "claim_type"
? (val: any) => (
<Badge variant={getClaimTypeVariant(val)} className="text-[10px] px-1.5 py-0">
{val}
</Badge>
)
: col.key === "claim_status"
? (val: any) => (
<Badge variant={getClaimStatusVariant(val)} className="text-[10px] px-1.5 py-0">
{val}
</Badge>
)
: undefined,
}))}
data={ts.groupData(data)}
loading={loading}
emptyMessage="등록된 클레임이 없어요"
rowKey={(row) => String(row.id)}
selectedId={selectedClaimNo ? String(data.find(c => c.claim_no === selectedClaimNo)?.id ?? "") : null}
onSelect={(id) => {
const claim = data.find(c => String(c.id) === id);
handleRowClick(claim?.claim_no ?? "");
}}
onRowDoubleClick={(row) => openEditModal(row.claim_no)}
showRowNumber
draggableColumns={false}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ── 우측: 클레임 상세 (35%) ── */}
<ResizablePanel defaultSize={35} minSize={20}>
<div className="flex flex-col h-full bg-card border-l">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-[13px] font-bold text-foreground"> </span>
{selectedClaim && (
<span className="text-[11px] font-mono text-muted-foreground">{selectedClaim.claim_no}</span>
)}
</div>
{selectedClaim && (
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => openEditModal(selectedClaim.claim_no)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(selectedClaim.claim_no)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
{/* 상태별 요약 카운트 */}
<div className="flex items-center gap-3 px-4 py-2 border-b bg-muted/20 shrink-0">
{([
{ label: "접수", count: statusCounts["접수"], variant: "default" as const },
{ label: "처리중", count: statusCounts["처리중"], variant: "secondary" as const },
{ label: "완료", count: statusCounts["완료"], variant: "outline" as const },
{ label: "취소", count: statusCounts["취소"], variant: "destructive" as const },
]).map((item) => (
<div key={item.label} className="flex items-center gap-1">
<Badge variant={item.variant} className="text-[10px] px-1.5 py-0">
{item.label}
</Badge>
<span className="text-sm font-bold tabular-nums text-foreground">{item.count}</span>
</div>
))}
</div>
{/* 상세 컨텐츠 */}
<div className="flex-1 overflow-auto">
{selectedClaim ? (
<div className="p-4 space-y-4">
{/* 기본 정보 그리드 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm text-foreground">{selectedClaim.claim_date}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm text-foreground">{selectedClaim.manager_name || "-"}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<Badge variant={getClaimTypeVariant(selectedClaim.claim_type)} className="text-[11px]">
{selectedClaim.claim_type}
</Badge>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<Badge variant={getClaimStatusVariant(selectedClaim.claim_status)} className="text-[11px]">
{selectedClaim.claim_status}
</Badge>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm font-medium text-foreground">{selectedClaim.customer_name}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm font-mono text-muted-foreground">{selectedClaim.order_no || "-"}</p>
</div>
</div>
<div className="border-t" />
{/* 클레임 내용 */}
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3.5 h-3.5 text-muted-foreground" />
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> </p>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
{selectedClaim.claim_content || "-"}
</div>
</div>
{/* 처리 내용 */}
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> </p>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
{selectedClaim.process_content || "-"}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-14 h-14 flex items-center justify-center mb-4">
<Inbox className="w-7 h-7 text-muted-foreground/30" />
</div>
<p className="text-sm font-medium text-muted-foreground mb-1">
</p>
<p className="text-xs text-muted-foreground/60">
</p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ───── 클레임 등록/수정 모달 ───── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "클레임 수정" : "클레임 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "클레임 정보를 수정해요." : "새로운 클레임을 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col md:flex-row gap-4 py-2">
{/* 왼쪽: 기본 정보 */}
<div className="md:w-[320px] shrink-0 space-y-3 bg-muted/30 p-4 rounded-lg border">
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h3>
<div className="space-y-1">
<Label htmlFor="claim_no" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="claim_no"
value={formData.claim_no || ""}
readOnly
className="h-9 text-sm bg-muted cursor-not-allowed font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="claim_date" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="claim_date"
type="date"
value={formData.claim_date || ""}
onChange={(e) => handleFormChange("claim_date", e.target.value)}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="claim_type" className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.claim_type || ""}
onValueChange={(v) => handleFormChange("claim_type", v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="유형을 선택해주세요" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="claim_status" className="text-[11px] font-semibold text-muted-foreground"> </Label>
<Select
value={formData.claim_status || "접수"}
onValueChange={(v) => handleFormChange("claim_status", v)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLAIM_STATUSES.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={customerOpen}
className="h-9 w-full justify-between font-normal"
onClick={() => fetchCustomers(false)}
>
{formData.customer_name || "거래처를 선택해주세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="거래처 검색..." />
<CommandList>
{customersLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-xs text-muted-foreground"> ...</span>
</div>
) : (
<>
<CommandEmpty className="py-4 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{customers.map((cust) => (
<CommandItem
key={cust.customerCode}
value={`${cust.customerCode} ${cust.customerName}`}
onSelect={() => {
setFormData((prev) => ({
...prev,
customer_code: cust.customerCode,
customer_name: cust.customerName,
}));
setCustomerOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.customer_code === cust.customerCode
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{cust.customerName}</span>
<span className="text-[10px] text-muted-foreground">{cust.customerCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label htmlFor="manager_name" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="manager_name"
value={formData.manager_name || ""}
onChange={(e) => handleFormChange("manager_name", e.target.value)}
placeholder="담당자를 입력해주세요"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Popover open={orderOpen} onOpenChange={setOrderOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={orderOpen}
className="h-9 w-full justify-between font-normal"
onClick={() => fetchSalesOrders(false)}
>
{formData.order_no || "수주번호를 선택해주세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="수주번호 검색..." />
<CommandList>
{ordersLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-xs text-muted-foreground"> ...</span>
</div>
) : (
<>
<CommandEmpty className="py-4 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{salesOrders.map((order) => (
<CommandItem
key={order.orderNo}
value={`${order.orderNo} ${order.partnerName}`}
onSelect={() => {
setFormData((prev) => ({
...prev,
order_no: order.orderNo,
}));
setOrderOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.order_no === order.orderNo
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{order.orderNo}</span>
<span className="text-[10px] text-muted-foreground">
{order.status}
{order.partnerName ? ` | ${order.partnerName}` : ""}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 오른쪽: 상세 내용 */}
<div className="flex-1 space-y-3 bg-muted/30 p-4 rounded-lg border min-w-0">
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h3>
<div className="space-y-1">
<Label htmlFor="claim_content" className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="claim_content"
value={formData.claim_content || ""}
onChange={(e) => handleFormChange("claim_content", e.target.value)}
placeholder="클레임 내용을 상세히 입력해주세요"
className="min-h-[200px] resize-y text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="process_content" className="text-[11px] font-semibold text-muted-foreground"> </Label>
<Textarea
id="process_content"
value={formData.process_content || ""}
onChange={(e) => handleFormChange("process_content", e.target.value)}
placeholder="처리 내용을 입력해주세요"
className="min-h-[150px] resize-y text-sm"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
onSuccess={() => {
fetchData();
toast.success("엑셀 업로드가 완료되었어요.");
}}
/>
{/* 확인 다이얼로그 */}
{ConfirmDialogComponent}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More