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:
kjs
2026-04-16 12:03:51 +09:00
parent 0e09b9e686
commit a20fd267fc
5 changed files with 341 additions and 108 deletions

View File

@@ -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 });
}
}
/**
* 테이블 데이터 추가
*/

View File

@@ -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);
/**
* 단일 레코드 조회 (자동 입력용)

View File

@@ -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}개 반환`

View File

@@ -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>

View File

@@ -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>