Merge branch 'gbpark-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-16 13:41:41 +09:00

View File

@@ -71,7 +71,8 @@ const LEFT_COLUMNS: DataGridColumn[] = [
// 우측: 품목 상세 (가로/세로/두께/면적 포함)
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "division", label: "구분", width: "w-[70px]" },
{ key: "division", label: "관리품목", width: "w-[90px]" },
{ key: "type", label: "품목구분", width: "w-[90px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[120px]" },
{ key: "spec", label: "규격", width: "w-[100px]" },
{ key: "width", label: "가로", width: "w-[65px]", formatNumber: true, align: "right" },
@@ -91,7 +92,8 @@ const RIGHT_COLUMNS: DataGridColumn[] = [
// 모달 품목 테이블 컬럼 (드래그 재정렬 + resize)
type ModalCol = { key: string; label: string; width: number };
const MODAL_DETAIL_COLUMNS: ModalCol[] = [
{ key: "division", label: "구분", width: 100 },
{ key: "division", label: "관리품목", width: 110 },
{ key: "type", label: "품목구분", width: 110 },
{ key: "part_name", label: "품명", width: 170 },
{ key: "spec", label: "규격", width: 110 },
{ key: "width", label: "가로", width: 90 },
@@ -106,6 +108,34 @@ const MODAL_DETAIL_COLUMNS: ModalCol[] = [
];
const MODAL_COL_KEY = "c30_sales_order_modal_col";
// sales_order_detail 행에 item_info의 division/type을 붙여줌 (detail 테이블엔 없고 item_info에만 있음)
async function enrichDetailsWithItemInfo<T extends Record<string, any>>(rows: T[]): Promise<T[]> {
const codes = [...new Set(rows.map((r) => r.part_code).filter(Boolean) as string[])];
if (codes.length === 0) return rows;
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
const map: Record<string, { division: string; type: string }> = {};
for (const i of items) {
map[i.item_number] = { division: i.division || "", type: i.type || "" };
}
return rows.map((r) => {
const info = r.part_code ? map[r.part_code] : undefined;
return {
...r,
division: r.division || info?.division || "",
type: r.type || info?.type || "",
};
});
} catch {
return rows;
}
}
function SortableModalHead({ col, onResize }: { col: ModalCol; onResize: (key: string, w: number) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const [resizing, setResizing] = useState(false);
@@ -208,12 +238,18 @@ export default function ChunganSalesOrderPage() {
if (!saved) return;
const parsed = JSON.parse(saved) as { key: string; width: number }[];
const byKey = new Map(parsed.map((c) => [c.key, c.width]));
const ordered = parsed
.map((p) => MODAL_DETAIL_COLUMNS.find((c) => c.key === p.key))
.filter(Boolean) as ModalCol[];
const missing = MODAL_DETAIL_COLUMNS.filter((c) => !byKey.has(c.key));
const merged = [...ordered, ...missing].map((c) => ({ ...c, width: byKey.get(c.key) ?? c.width }));
setModalColumns(merged);
const savedKeys = new Set(parsed.map((p) => p.key));
const defaultKeys = new Set(MODAL_DETAIL_COLUMNS.map((c) => c.key));
// 저장된 키셋이 기본 정의와 정확히 일치할 때만 저장된 순서 사용,
// 그렇지 않으면 (새 컬럼 추가 등) 기본 정의 순서를 따르고 너비만 복원
if (savedKeys.size === defaultKeys.size && [...savedKeys].every((k) => defaultKeys.has(k))) {
const ordered = parsed
.map((p) => MODAL_DETAIL_COLUMNS.find((c) => c.key === p.key))
.filter(Boolean) as ModalCol[];
setModalColumns(ordered.map((c) => ({ ...c, width: byKey.get(c.key) ?? c.width })));
} else {
setModalColumns(MODAL_DETAIL_COLUMNS.map((c) => ({ ...c, width: byKey.get(c.key) ?? c.width })));
}
} catch { /* skip */ }
}, []);
@@ -353,6 +389,7 @@ export default function ChunganSalesOrderPage() {
allRows = dRes.data?.data?.data || dRes.data?.data?.rows || [];
} catch { /* skip */ }
}
allRows = await enrichDetailsWithItemInfo(allRows);
setAllDetails(allRows);
const masterMap: Record<string, any> = {};
@@ -458,6 +495,7 @@ export default function ChunganSalesOrderPage() {
.map((d) => ({
...d,
division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "",
type: categoryOptions["item_type"]?.find((o) => o.code === d.type)?.label || d.type || "",
}));
setDetailItems(items);
}, [selectedOrderNo, allDetails, categoryOptions]);
@@ -500,7 +538,8 @@ export default function ChunganSalesOrderPage() {
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
const rawDetail = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
const detailData = await enrichDetailsWithItemInfo(rawDetail);
setMasterForm(masterData || {});
setModalDetailRows(detailData.map((d: any, i: number) => ({
@@ -508,6 +547,7 @@ export default function ChunganSalesOrderPage() {
_id: d.id || `row_${i}`,
_fromItemInfo: !!d.part_code,
_divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "",
_typeLabel: categoryOptions["item_type"]?.find((o: any) => o.code === d.type)?.label || d.type || "",
})));
setIsEditMode(true);
setIsModalOpen(true);
@@ -565,29 +605,24 @@ export default function ChunganSalesOrderPage() {
}
};
// 품목 자동 등록 (item_info에 없으면 등록)
// 품목 자동 등록 (품목검색으로 가져온 기존 품목은 skip, 행추가한 건 무조건 신규 INSERT)
const autoRegisterItems = async (rows: any[]) => {
for (const row of rows) {
if (row.part_code || !row.part_name) continue;
if (row._fromItemInfo || row.part_code) continue;
if (!row.part_name) continue;
try {
// item_info에서 품명으로 검색
const searchRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] },
autoFilter: true,
});
const found = (searchRes.data?.data?.data || searchRes.data?.data?.rows || [])[0];
if (found) {
row.part_code = found.item_number;
continue;
}
// 없으면 자동 등록
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
item_name: row.part_name,
division: row.division || "",
type: row.type || "",
size: row.spec || "",
unit: row.unit || "",
width: row.width || "",
height: row.height || "",
thickness: row.thickness || "",
});
// 등록 후 재조회하여 item_number 획득
// 방금 등록된 레코드의 item_number 획득 (동명 중복이 있을 수 있으므로 최신 1건)
const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] },
@@ -651,7 +686,7 @@ export default function ChunganSalesOrderPage() {
for (let i = 0; i < modalDetailRows.length; i++) {
const row = modalDetailRows[i];
const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
const { _id, _fromItemInfo, _divisionLabel, _typeLabel, division: _div, type: _typ, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
id: crypto.randomUUID(),
...detailFields,
@@ -731,7 +766,10 @@ export default function ChunganSalesOrderPage() {
setModalDetailRows((prev) => [...prev, {
_id: `new_${Date.now()}_${Math.random()}`,
_fromItemInfo: false,
part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡",
part_code: "", part_name: "", spec: "",
division: "", _divisionLabel: "",
type: "", _typeLabel: "",
unit: "㎡",
width: "", height: "", thickness: "", area: "",
qty: "", unit_price: "", amount: "",
due_date: "", memo: "",
@@ -785,12 +823,23 @@ export default function ChunganSalesOrderPage() {
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4}>
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
case "type":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row._typeLabel || "-"}</span>
) : (
<Select value={row.type || ""} onValueChange={(v) => updateDetailRow(idx, "type", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="품목구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4}>
{(categoryOptions["item_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
case "part_name":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row.part_name || "-"}</span>