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:
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user