Merge pull request 'Implement pagination for source data in Outbound, Receiving, and Shipping Order pages' (#433) from jskim-node into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/433
This commit is contained in:
kjs 2026-03-30 12:15:05 +09:00
commit 45a92de60b
4 changed files with 219 additions and 34 deletions

View File

@ -37,6 +37,9 @@ import {
X,
Save,
ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@ -131,6 +134,10 @@ export default function OutboundPage() {
const [items, setItems] = useState<ItemSource[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
// 소스 데이터 페이징 (클라이언트 사이드)
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize, setSourcePageSize] = useState(20);
// 날짜 초기화
useEffect(() => {
const today = new Date();
@ -261,13 +268,44 @@ export default function OutboundPage() {
};
const searchSourceData = useCallback(async () => {
setSourcePage(1);
await loadSourceData(modalOutboundType, sourceKeyword || undefined);
}, [modalOutboundType, sourceKeyword, loadSourceData]);
// 현재 출고유형에 따른 전체 소스 데이터
const allSourceData = useMemo(() => {
if (modalOutboundType === "판매출고") return shipmentInstructions;
if (modalOutboundType === "반품출고") return purchaseOrders;
return items;
}, [modalOutboundType, shipmentInstructions, purchaseOrders, items]);
const sourceTotalCount = allSourceData.length;
const sourceTotalPages = Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize));
// 현재 페이지에 해당하는 slice
const pagedShipmentInstructions = useMemo(() => {
if (modalOutboundType !== "판매출고") return [];
const start = (sourcePage - 1) * sourcePageSize;
return shipmentInstructions.slice(start, start + sourcePageSize);
}, [modalOutboundType, shipmentInstructions, sourcePage, sourcePageSize]);
const pagedPurchaseOrders = useMemo(() => {
if (modalOutboundType !== "반품출고") return [];
const start = (sourcePage - 1) * sourcePageSize;
return purchaseOrders.slice(start, start + sourcePageSize);
}, [modalOutboundType, purchaseOrders, sourcePage, sourcePageSize]);
const pagedItems = useMemo(() => {
if (modalOutboundType !== "기타출고") return [];
const start = (sourcePage - 1) * sourcePageSize;
return items.slice(start, start + sourcePageSize);
}, [modalOutboundType, items, sourcePage, sourcePageSize]);
const handleOutboundTypeChange = useCallback(
(type: string) => {
setModalOutboundType(type);
setSourceKeyword("");
setSourcePage(1);
setShipmentInstructions([]);
setPurchaseOrders([]);
setItems([]);
@ -686,6 +724,7 @@ export default function OutboundPage() {
defaultMaxWidth="sm:max-w-[1600px]"
defaultWidth="w-[95vw]"
className="h-[90vh] p-0"
contentClassName="overflow-hidden flex flex-col"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
@ -774,43 +813,87 @@ export default function OutboundPage() {
</Button>
</div>
<div className="flex-1 overflow-auto px-4 py-2">
<h4 className="text-muted-foreground mb-2 text-xs font-semibold">
<div className="flex items-center justify-between border-b px-4 py-2 shrink-0">
<h4 className="text-muted-foreground text-xs font-semibold">
{modalOutboundType === "판매출고"
? "미출고 출하지시 목록"
: modalOutboundType === "반품출고"
? "입고된 발주 목록"
: "품목 목록"}
</h4>
{sourceTotalCount > 0 && (
<span className="text-muted-foreground text-[11px]"> {sourceTotalCount}</span>
)}
</div>
<div className="flex-1 overflow-auto">
{sourceLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : modalOutboundType === "판매출고" ? (
<SourceShipmentInstructionTable
data={shipmentInstructions}
data={pagedShipmentInstructions}
onAdd={addShipmentInstruction}
selectedKeys={selectedItems.map((s) => s.key)}
/>
) : modalOutboundType === "반품출고" ? (
<SourcePurchaseOrderTable
data={purchaseOrders}
data={pagedPurchaseOrders}
onAdd={addPurchaseOrder}
selectedKeys={selectedItems.map((s) => s.key)}
/>
) : (
<SourceItemTable
data={items}
data={pagedItems}
onAdd={addItem}
selectedKeys={selectedItems.map((s) => s.key)}
/>
)}
</div>
{/* 페이징 바 */}
{sourceTotalCount > 0 && (
<div className="flex items-center justify-between border-t bg-muted/10 px-4 py-2 shrink-0">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[11px]">:</span>
<Input
type="number"
min={1}
max={500}
value={sourcePageSize}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (v > 0) { setSourcePageSize(v); setSourcePage(1); }
}}
className="h-7 w-[60px] text-center text-[11px]"
/>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => setSourcePage(1)}>
<ChevronsLeft className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => setSourcePage((p) => p - 1)}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-xs font-medium px-2">{sourcePage} / {sourceTotalPages}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= sourceTotalPages}
onClick={() => setSourcePage((p) => p + 1)}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= sourceTotalPages}
onClick={() => setSourcePage(sourceTotalPages)}>
<ChevronsRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
{/* 우측: 출고 정보 + 선택 품목 */}
<ResizablePanel defaultSize={40} minSize={25}>

View File

@ -37,6 +37,9 @@ import {
X,
Save,
ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@ -132,6 +135,11 @@ export default function ReceivingPage() {
const [items, setItems] = useState<ItemSource[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
// 소스 데이터 페이징
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize, setSourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 날짜 초기화
useEffect(() => {
const today = new Date();
@ -214,18 +222,32 @@ export default function ReceivingPage() {
// 소스 데이터 로드 함수
const loadSourceData = useCallback(
async (type: string, keyword?: string) => {
async (type: string, keyword?: string, pageOverride?: number) => {
setSourceLoading(true);
try {
const params = {
keyword: keyword || undefined,
page: pageOverride ?? sourcePage,
pageSize: sourcePageSize,
};
if (type === "구매입고") {
const res = await getPurchaseOrderSources(keyword || undefined);
if (res.success) setPurchaseOrders(res.data);
const res = await getPurchaseOrderSources(params);
if (res.success) {
setPurchaseOrders(res.data);
setSourceTotalCount(res.totalCount || 0);
}
} else if (type === "반품입고") {
const res = await getShipmentSources(keyword || undefined);
if (res.success) setShipments(res.data);
const res = await getShipmentSources(params);
if (res.success) {
setShipments(res.data);
setSourceTotalCount(res.totalCount || 0);
}
} else {
const res = await getItemSources(keyword || undefined);
if (res.success) setItems(res.data);
const res = await getItemSources(params);
if (res.success) {
setItems(res.data);
setSourceTotalCount(res.totalCount || 0);
}
}
} catch {
// ignore
@ -233,7 +255,7 @@ export default function ReceivingPage() {
setSourceLoading(false);
}
},
[]
[sourcePage, sourcePageSize]
);
const openRegisterModal = async () => {
@ -250,13 +272,15 @@ export default function ReceivingPage() {
setPurchaseOrders([]);
setShipments([]);
setItems([]);
setSourcePage(1);
setSourceTotalCount(0);
setIsModalOpen(true);
// 입고번호 생성 + 발주 데이터 동시 로드
try {
const [numRes] = await Promise.all([
generateReceivingNumber(),
loadSourceData(defaultType),
loadSourceData(defaultType, undefined, 1),
]);
if (numRes.success) setModalInboundNo(numRes.data);
} catch {
@ -266,7 +290,8 @@ export default function ReceivingPage() {
// 검색 버튼 클릭 시
const searchSourceData = useCallback(async () => {
await loadSourceData(modalInboundType, sourceKeyword || undefined);
setSourcePage(1);
await loadSourceData(modalInboundType, sourceKeyword || undefined, 1);
}, [modalInboundType, sourceKeyword, loadSourceData]);
// 입고유형 변경 시 소스 데이터 자동 리로드
@ -278,7 +303,9 @@ export default function ReceivingPage() {
setShipments([]);
setItems([]);
setSelectedItems([]);
loadSourceData(type);
setSourcePage(1);
setSourceTotalCount(0);
loadSourceData(type, undefined, 1);
},
[loadSourceData]
);
@ -303,7 +330,7 @@ export default function ReceivingPage() {
inbound_qty: po.remain_qty,
unit_price: po.unit_price,
total_amount: po.remain_qty * po.unit_price,
source_table: "purchase_order_mng",
source_table: po.source_table || "purchase_order_mng",
source_id: po.id,
},
]);
@ -694,6 +721,7 @@ export default function ReceivingPage() {
defaultMaxWidth="sm:max-w-[1600px]"
defaultWidth="w-[95vw]"
className="h-[90vh] p-0"
contentClassName="overflow-hidden flex flex-col"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
@ -817,10 +845,56 @@ export default function ReceivingPage() {
/>
)}
</div>
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="flex shrink-0 items-center justify-between border-t bg-muted/10 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[11px]">:</span>
<Input
type="number"
min={1}
max={500}
value={sourcePageSize}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (v > 0) {
setSourcePageSize(v);
setSourcePage(1);
loadSourceData(modalInboundType, sourceKeyword || undefined, 1);
}
}}
className="h-7 w-[60px] text-center text-[11px]"
/>
<span className="text-muted-foreground text-[11px]">
{sourceTotalCount}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { setSourcePage(1); loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }}>
<ChevronsLeft className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { const p = sourcePage - 1; setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="px-2 text-xs font-medium">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = sourcePage + 1; setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); loadSourceData(modalInboundType, sourceKeyword || undefined, p); }}>
<ChevronsRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
{/* 우측: 입고 정보 + 선택 품목 */}
<ResizablePanel defaultSize={40} minSize={25}>
@ -1030,7 +1104,7 @@ function SourcePurchaseOrderTable({
return (
<Table>
<TableHeader>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>
@ -1109,7 +1183,7 @@ function SourceShipmentTable({
return (
<Table>
<TableHeader>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>
@ -1186,7 +1260,7 @@ function SourceItemTable({
return (
<Table>
<TableHeader>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="text-[11px]">
<TableHead className="w-[40px] p-2" />
<TableHead className="p-2"></TableHead>

View File

@ -81,7 +81,7 @@ export default function SalesItemPage() {
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string; isDefault?: boolean }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
@ -125,11 +125,11 @@ export default function SalesItemPage() {
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
const optMap: Record<string, { code: string; label: string; isDefault?: boolean }[]> = {};
const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => {
const result: { code: string; label: string; isDefault?: boolean }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
@ -164,7 +164,11 @@ export default function SalesItemPage() {
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭)
filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" });
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,

View File

@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
@ -117,7 +117,7 @@ export default function ShippingOrderPage() {
const [sourceLoading, setSourceLoading] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize] = useState(20);
const [sourcePageSize, setSourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 텍스트 입력 debounce (500ms)
@ -592,6 +592,8 @@ export default function ShippingOrderPage() {
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
defaultMaxWidth="max-w-[90vw]"
defaultWidth="w-[1400px]"
className="h-[90vh]"
contentClassName="overflow-hidden flex flex-col"
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
@ -694,10 +696,28 @@ export default function ShippingOrderPage() {
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{sourceTotalCount} {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[11px]">:</span>
<Input
type="number"
min={1}
max={500}
value={sourcePageSize}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); }
}}
className="h-7 w-[60px] text-center text-[11px]"
/>
<span className="text-muted-foreground text-[11px]">
{sourceTotalCount}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { setSourcePage(1); fetchSourceData(1); }}>
<ChevronsLeft className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronLeft className="w-3.5 h-3.5" />
@ -707,13 +727,17 @@ export default function ShippingOrderPage() {
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronRight className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); fetchSourceData(p); }}>
<ChevronsRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
{/* 오른쪽: 폼 */}
<ResizablePanel defaultSize={45} minSize={30}>