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

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/15
This commit is contained in:
jskim
2026-04-08 08:59:11 +00:00
131 changed files with 5937 additions and 5972 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);
@@ -229,15 +229,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]
);
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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 () => {
@@ -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)
@@ -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 () => {
+151 -420
View File
@@ -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>
);
})}

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