feat: Enhance user management and reporting features
- Added `end_date` field to user management for better tracking of user status. - Updated SQL queries in `adminController` to include `end_date` during user save operations. - Improved purchase report data handling by refining the logic for received quantities. - Enhanced file preview functionality to streamline file path handling. - Updated outbound and receiving controllers to ensure accurate updates to shipment and purchase order details. These changes aim to improve the overall functionality and user experience in managing user data and reporting processes.
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}
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -820,12 +820,14 @@ export default function CustomerManagementPage() {
|
||||
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 +1231,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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -820,12 +820,14 @@ export default function CustomerManagementPage() {
|
||||
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 +1231,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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -820,12 +820,14 @@ export default function CustomerManagementPage() {
|
||||
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 +1231,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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -820,12 +820,14 @@ export default function CustomerManagementPage() {
|
||||
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 +1231,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 () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -820,12 +820,14 @@ export default function CustomerManagementPage() {
|
||||
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 +1231,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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -312,37 +313,40 @@ export default function DepartmentPage() {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user