feat: Add table aggregation endpoint for data summarization
- Implemented a new endpoint to retrieve aggregated data (SUM/COUNT) for specified columns in a given table. - Added validation to ensure the presence of table name and valid aggregation columns in the request. - Integrated company code filtering to restrict data access based on user permissions. - Updated the table management routes to include the new aggregation functionality. - Enhanced the frontend order page to utilize the new aggregation endpoint for improved statistical reporting.
This commit is contained in:
@@ -907,6 +907,61 @@ export async function getTableData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 집계 조회 (SUM/COUNT)
|
||||
* POST /api/table-management/tables/:tableName/aggregate
|
||||
*/
|
||||
export async function getTableAggregate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columns, autoFilter } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!tableName || !columns || !Array.isArray(columns)) {
|
||||
res.status(400).json({ success: false, message: "tableName과 columns 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validCols = columns.filter((c: any) =>
|
||||
c.column && c.func && /^[a-zA-Z0-9_]+$/.test(c.column) && ["sum", "count", "avg", "min", "max"].includes(c.func)
|
||||
);
|
||||
if (validCols.length === 0) {
|
||||
res.status(400).json({ success: false, message: "유효한 집계 컬럼이 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectParts = validCols.map((c: any) => {
|
||||
const col = c.column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
return `${c.func}(COALESCE(CAST(NULLIF(${col}, '') AS numeric), 0)) AS "${c.func}_${col}"`;
|
||||
});
|
||||
|
||||
let whereClause = "";
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (autoFilter !== false && companyCode && companyCode !== "*") {
|
||||
whereClause = `WHERE company_code = $${paramIdx}`;
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = (await import("../database/db")).getPool();
|
||||
const safeTable = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectParts.join(", ")} FROM ${safeTable} ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows[0] || {} });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 집계 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 추가
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
updateColumnInputType,
|
||||
updateTableLabel,
|
||||
getTableData,
|
||||
getTableRecord, // 🆕 단일 레코드 조회
|
||||
getTableRecord,
|
||||
getTableAggregate,
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
@@ -193,6 +194,7 @@ router.get("/health", checkDatabaseConnection);
|
||||
* POST /api/table-management/tables/:tableName/data
|
||||
*/
|
||||
router.post("/tables/:tableName/data", getTableData);
|
||||
router.post("/tables/:tableName/aggregate", getTableAggregate);
|
||||
|
||||
/**
|
||||
* 단일 레코드 조회 (자동 입력용)
|
||||
|
||||
@@ -2367,26 +2367,24 @@ export class TableManagementService {
|
||||
const total = parseInt(countResult[0].count);
|
||||
|
||||
// 데이터 조회 (main 별칭 추가)
|
||||
const dataQuery = `
|
||||
SELECT main.* FROM ${safeTableName} main
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
// size=0 이면 LIMIT 없이 전체 반환 (마스터 참조 데이터 조회용)
|
||||
const usePaging = size > 0;
|
||||
const dataQuery = usePaging
|
||||
? `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`
|
||||
: `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause}`;
|
||||
|
||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||
logger.info(
|
||||
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
||||
);
|
||||
const queryParams = usePaging ? [...searchValues, size, offset] : [...searchValues];
|
||||
logger.info(`🔍 파라미터: ${JSON.stringify(queryParams)}`);
|
||||
|
||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||
let data = await query<any>(dataQuery, queryParams);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
if (fileColumns.length > 0) {
|
||||
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
const totalPages = usePaging ? Math.ceil(total / size) : 1;
|
||||
|
||||
logger.info(
|
||||
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
ClipboardList, Package, Search, X, Settings2, GripVertical,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -94,6 +95,12 @@ export default function JeilGlassOrderPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
// 전체 통계 (서버에서 별도 집계)
|
||||
const [totalStats, setTotalStats] = useState<{ totalAmount: number; totalQty: number }>({ totalAmount: 0, totalQty: 0 });
|
||||
// 좌측 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||||
const [selectedOrderNo, setSelectedOrderNo] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
@@ -168,7 +175,7 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
@@ -198,7 +205,7 @@ export default function JeilGlassOrderPage() {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 수주 목록 조회 (디테일 전체 → order_no 그룹핑)
|
||||
// 수주 목록 조회 (마스터 서버 페이징 → 디테일 조인)
|
||||
const fetchMasterOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -207,29 +214,36 @@ export default function JeilGlassOrderPage() {
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
|
||||
// 1단계: 마스터 서버 페이징 조회
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: currentPage, size: pageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setAllDetails(rows);
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
const serverTotal = mRes.data?.data?.total || mRes.data?.data?.totalCount || masters.length;
|
||||
setTotalCount(serverTotal);
|
||||
|
||||
// 마스터 조회 (거래처 정보 확보)
|
||||
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
|
||||
let masterMap: Record<string, any> = {};
|
||||
// 2단계: 해당 페이지 마스터의 order_no로 디테일 조회
|
||||
const orderNos = masters.map((m: any) => m.order_no).filter(Boolean);
|
||||
let allRows: any[] = [];
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
const dRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length * 50,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
allRows = dRes.data?.data?.data || dRes.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setAllDetails(allRows);
|
||||
|
||||
const masterMap: Record<string, any> = {};
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
|
||||
// 거래처 코드 → 이름 변환
|
||||
const resolvePartner = (code: string) => {
|
||||
@@ -237,26 +251,28 @@ export default function JeilGlassOrderPage() {
|
||||
return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code;
|
||||
};
|
||||
|
||||
// order_no 기준 집계
|
||||
// order_no 기준 집계 (마스터 기반으로 생성, 디테일 누적)
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const row of rows) {
|
||||
const no = row.order_no;
|
||||
for (const m of masters) {
|
||||
const no = m.order_no;
|
||||
if (!no) continue;
|
||||
if (!grouped[no]) {
|
||||
const master = masterMap[no] || {};
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(master.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: row.due_date || "",
|
||||
status: master.status || "",
|
||||
};
|
||||
}
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(m.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: m.due_date || "",
|
||||
status: m.status || "",
|
||||
};
|
||||
}
|
||||
// 디테일 기준 집계
|
||||
for (const row of allRows) {
|
||||
const no = row.order_no;
|
||||
if (!no || !grouped[no]) continue;
|
||||
const g = grouped[no];
|
||||
g.item_count += 1;
|
||||
g.total_qty += parseFloat(row.qty) || 0;
|
||||
@@ -267,26 +283,59 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
const list = Object.values(grouped);
|
||||
setMasterOrders(list);
|
||||
setTotalCount(list.length);
|
||||
|
||||
// 전체 통계: DB에서 직접 SUM (size:99999 전체 조회 대신)
|
||||
try {
|
||||
const aggRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/aggregate`, {
|
||||
columns: [
|
||||
{ column: "qty", func: "sum" },
|
||||
{ column: "amount", func: "sum" },
|
||||
],
|
||||
autoFilter: true,
|
||||
});
|
||||
if (aggRes.data?.success) {
|
||||
setTotalStats({
|
||||
totalQty: Number(aggRes.data.data?.sum_qty) || 0,
|
||||
totalAmount: Number(aggRes.data.data?.sum_amount) || 0,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions]);
|
||||
}, [searchFilters, categoryOptions, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]);
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
let totalAmount = 0, totalQty = 0;
|
||||
for (const m of masterOrders) {
|
||||
totalAmount += m.total_amount || 0;
|
||||
totalQty += m.total_qty || 0;
|
||||
// 서버 페이징 계산
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); }
|
||||
else setPageSizeInput(String(pageSize));
|
||||
};
|
||||
|
||||
const getPageNumbers = (): (number | "...")[] => {
|
||||
const pages: (number | "...")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (safePage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i);
|
||||
if (safePage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return { totalAmount, totalQty };
|
||||
}, [masterOrders]);
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 통계 (전체 기준)
|
||||
const stats = totalStats;
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
useEffect(() => {
|
||||
@@ -766,6 +815,7 @@ export default function JeilGlassOrderPage() {
|
||||
data={masterOrders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
showPagination={false}
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowClick={handleMasterRowClick}
|
||||
@@ -773,6 +823,38 @@ export default function JeilGlassOrderPage() {
|
||||
tableName={MASTER_TABLE}
|
||||
emptyMessage="등록된 수주가 없습니다"
|
||||
/>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>전체 <span className="font-medium text-foreground">{totalCount}</span>건</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="number" min={1} value={pageSizeInput}
|
||||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||
onBlur={applyPageSize}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||
className="h-7 w-16 text-center text-xs" />
|
||||
<span>건씩</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<span>{safePage} / {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
ClipboardList, Package, Search, X, Settings2, GripVertical,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -94,6 +95,12 @@ export default function JeilGlassOrderPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
// 전체 통계 (서버에서 별도 집계)
|
||||
const [totalStats, setTotalStats] = useState<{ totalAmount: number; totalQty: number }>({ totalAmount: 0, totalQty: 0 });
|
||||
// 좌측 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [pageSizeInput, setPageSizeInput] = useState("20");
|
||||
const [selectedOrderNo, setSelectedOrderNo] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
@@ -168,7 +175,7 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
@@ -198,7 +205,7 @@ export default function JeilGlassOrderPage() {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 수주 목록 조회 (디테일 전체 → order_no 그룹핑)
|
||||
// 수주 목록 조회 (마스터 서버 페이징 → 디테일 조인)
|
||||
const fetchMasterOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -207,29 +214,36 @@ export default function JeilGlassOrderPage() {
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
|
||||
// 1단계: 마스터 서버 페이징 조회
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: currentPage, size: pageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setAllDetails(rows);
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
const serverTotal = mRes.data?.data?.total || mRes.data?.data?.totalCount || masters.length;
|
||||
setTotalCount(serverTotal);
|
||||
|
||||
// 마스터 조회 (거래처 정보 확보)
|
||||
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
|
||||
let masterMap: Record<string, any> = {};
|
||||
// 2단계: 해당 페이지 마스터의 order_no로 디테일 조회
|
||||
const orderNos = masters.map((m: any) => m.order_no).filter(Boolean);
|
||||
let allRows: any[] = [];
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
const dRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length * 50,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
allRows = dRes.data?.data?.data || dRes.data?.data?.rows || [];
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setAllDetails(allRows);
|
||||
|
||||
const masterMap: Record<string, any> = {};
|
||||
for (const m of masters) masterMap[m.order_no] = m;
|
||||
|
||||
// 거래처 코드 → 이름 변환
|
||||
const resolvePartner = (code: string) => {
|
||||
@@ -237,26 +251,28 @@ export default function JeilGlassOrderPage() {
|
||||
return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code;
|
||||
};
|
||||
|
||||
// order_no 기준 집계
|
||||
// order_no 기준 집계 (마스터 기반으로 생성, 디테일 누적)
|
||||
const grouped: Record<string, any> = {};
|
||||
for (const row of rows) {
|
||||
const no = row.order_no;
|
||||
for (const m of masters) {
|
||||
const no = m.order_no;
|
||||
if (!no) continue;
|
||||
if (!grouped[no]) {
|
||||
const master = masterMap[no] || {};
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(master.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: row.due_date || "",
|
||||
status: master.status || "",
|
||||
};
|
||||
}
|
||||
grouped[no] = {
|
||||
id: `master_${no}`,
|
||||
order_no: no,
|
||||
partner_name: resolvePartner(m.partner_id),
|
||||
item_count: 0,
|
||||
total_qty: 0,
|
||||
total_ship_qty: 0,
|
||||
total_balance: 0,
|
||||
total_amount: 0,
|
||||
due_date: m.due_date || "",
|
||||
status: m.status || "",
|
||||
};
|
||||
}
|
||||
// 디테일 기준 집계
|
||||
for (const row of allRows) {
|
||||
const no = row.order_no;
|
||||
if (!no || !grouped[no]) continue;
|
||||
const g = grouped[no];
|
||||
g.item_count += 1;
|
||||
g.total_qty += parseFloat(row.qty) || 0;
|
||||
@@ -267,26 +283,59 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
const list = Object.values(grouped);
|
||||
setMasterOrders(list);
|
||||
setTotalCount(list.length);
|
||||
|
||||
// 전체 통계: DB에서 직접 SUM (size:99999 전체 조회 대신)
|
||||
try {
|
||||
const aggRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/aggregate`, {
|
||||
columns: [
|
||||
{ column: "qty", func: "sum" },
|
||||
{ column: "amount", func: "sum" },
|
||||
],
|
||||
autoFilter: true,
|
||||
});
|
||||
if (aggRes.data?.success) {
|
||||
setTotalStats({
|
||||
totalQty: Number(aggRes.data.data?.sum_qty) || 0,
|
||||
totalAmount: Number(aggRes.data.data?.sum_amount) || 0,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions]);
|
||||
}, [searchFilters, categoryOptions, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]);
|
||||
|
||||
// 통계
|
||||
const stats = useMemo(() => {
|
||||
let totalAmount = 0, totalQty = 0;
|
||||
for (const m of masterOrders) {
|
||||
totalAmount += m.total_amount || 0;
|
||||
totalQty += m.total_qty || 0;
|
||||
// 서버 페이징 계산
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); }
|
||||
else setPageSizeInput(String(pageSize));
|
||||
};
|
||||
|
||||
const getPageNumbers = (): (number | "...")[] => {
|
||||
const pages: (number | "...")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (safePage > 3) pages.push("...");
|
||||
for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i);
|
||||
if (safePage < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return { totalAmount, totalQty };
|
||||
}, [masterOrders]);
|
||||
return pages;
|
||||
};
|
||||
|
||||
// 통계 (전체 기준)
|
||||
const stats = totalStats;
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
useEffect(() => {
|
||||
@@ -537,19 +586,27 @@ export default function JeilGlassOrderPage() {
|
||||
if (!code) return "";
|
||||
return categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const newRows = selected.map((item) => ({
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: true,
|
||||
part_code: item.item_number || "",
|
||||
part_name: item.item_name || "",
|
||||
spec: item.size || "",
|
||||
division: item.division || "",
|
||||
_divisionLabel: resolveDivision(item.division),
|
||||
unit: resolveUnit(item.unit) || "",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
}));
|
||||
const newRows = selected.map((item) => {
|
||||
const w = parseFloat(item.width) || 0;
|
||||
const h = parseFloat(item.height) || 0;
|
||||
const autoArea = w > 0 && h > 0 ? String(Math.round((w * h / 1_000_000) * 10000) / 10000) : "";
|
||||
return {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: true,
|
||||
part_code: item.item_number || "",
|
||||
part_name: item.item_name || "",
|
||||
spec: item.size || "",
|
||||
division: item.division || "",
|
||||
_divisionLabel: resolveDivision(item.division),
|
||||
unit: resolveUnit(item.unit) || "",
|
||||
width: item.width || "",
|
||||
height: item.height || "",
|
||||
thickness: item.thickness || "",
|
||||
area: item.area || autoArea,
|
||||
qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
};
|
||||
});
|
||||
setModalDetailRows((prev) => [...prev, ...newRows]);
|
||||
setItemSelectOpen(false);
|
||||
setItemCheckedIds(new Set());
|
||||
@@ -758,6 +815,7 @@ export default function JeilGlassOrderPage() {
|
||||
data={masterOrders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
showPagination={false}
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowClick={handleMasterRowClick}
|
||||
@@ -765,6 +823,38 @@ export default function JeilGlassOrderPage() {
|
||||
tableName={MASTER_TABLE}
|
||||
emptyMessage="등록된 수주가 없습니다"
|
||||
/>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>전체 <span className="font-medium text-foreground">{totalCount}</span>건</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="number" min={1} value={pageSizeInput}
|
||||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||
onBlur={applyPageSize}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||
className="h-7 w-16 text-center text-xs" />
|
||||
<span>건씩</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages} className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<span>{safePage} / {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -1080,13 +1170,16 @@ export default function JeilGlassOrderPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">품목코드</TableHead>
|
||||
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">가로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">세로</TableHead>
|
||||
<TableHead className="w-[60px] text-right">두께</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemCheckedIds((prev) => {
|
||||
@@ -1097,6 +1190,9 @@ export default function JeilGlassOrderPage() {
|
||||
<TableCell className="text-center"><input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-xs">{item.item_number}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_name}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.width || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.height || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right font-mono">{item.thickness || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.size}</TableCell>
|
||||
<TableCell className="text-xs">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
Reference in New Issue
Block a user