feat: Implement pagination and enhanced keyword search in work instruction retrieval

- Added pagination support to the `getList` function in the work instruction controller, allowing for efficient data retrieval with `page` and `pageSize` parameters.
- Enhanced the keyword search functionality to include checks for item numbers in the work instruction details, improving search accuracy.
- Updated the frontend components to utilize the new `SmartSelect` component for supplier and partner selection, enhancing user experience.
- Adjusted the `EDataTable` component to support server-side pagination, ensuring better performance with large datasets.
This commit is contained in:
kjs
2026-04-20 14:51:32 +09:00
parent 377e3e51e8
commit 68bc857eae
19 changed files with 260 additions and 158 deletions

View File

@@ -23,7 +23,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
try {
await ensureDetailRoutingColumn();
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
const paginated = pageNum !== null && sizeNum !== null;
const conditions: string[] = [];
const params: any[] = [];
@@ -54,14 +59,110 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
params.push(progressStatus);
idx++;
}
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
conditions.push(`(
wi.work_instruction_no ILIKE $${idx}
OR wi.worker ILIKE $${idx}
OR EXISTS (
SELECT 1 FROM work_instruction_detail dd
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
WHERE dd.work_instruction_id = wi.id
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
)
)`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const pool = getPool();
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
if (paginated) {
// 1) 총 WI 개수 카운트
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM work_instruction wi
${whereClause}
`;
const countRes = await pool.query(countSql, params);
const totalCount = countRes.rows[0]?.cnt ?? 0;
// 2) 현재 페이지 WI id 목록
const offset = (pageNum! - 1) * sizeNum!;
const pageSql = `
SELECT wi.id
FROM work_instruction wi
${whereClause}
ORDER BY wi.created_date DESC, wi.id DESC
LIMIT ${sizeNum} OFFSET ${offset}
`;
const pageRes = await pool.query(pageSql, params);
const wiIds = pageRes.rows.map((r) => r.id);
if (wiIds.length === 0) {
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
}
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
const dataSql = `
SELECT
wi.id AS wi_id,
wi.work_instruction_no,
wi.status,
wi.progress_status,
wi.qty AS total_qty,
wi.completed_qty,
wi.start_date,
wi.end_date,
wi.equipment_id,
wi.work_team,
wi.worker,
wi.remark AS wi_remark,
wi.created_date,
d.id AS detail_id,
d.item_number,
d.qty AS detail_qty,
d.remark AS detail_remark,
d.part_code,
d.source_table,
d.source_id,
d.routing_version_id AS detail_routing_version_id,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.type, '') AS item_type,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
wi.routing AS routing_version_id,
COALESCE(rv.version_name, '') AS routing_name,
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv
ON wi.routing = rv.id AND rv.company_code = wi.company_code
WHERE wi.id = ANY($1::varchar[])
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
`;
const dataRes = await pool.query(dataSql, [wiIds]);
return res.json({
success: true,
data: dataRes.rows,
totalCount,
page: pageNum,
pageSize: sizeNum,
});
}
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
const query = `
SELECT
wi.id AS wi_id,
@@ -97,17 +198,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_id = wi.id
LEFT JOIN LATERAL (
SELECT item_name, size, type FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN item_info itm
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1026,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1034,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1028,7 +1029,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1036,15 +1038,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1026,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1034,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>

View File

@@ -89,6 +89,7 @@ export default function ProductionResultPage() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [pageSizeInput, setPageSizeInput] = useState("20");
const [wiTotalCount, setWiTotalCount] = useState(0);
// ── 우측: 실적 ──
const [rightTab, setRightTab] = useState<"result" | "defect">("result");
@@ -135,7 +136,7 @@ export default function ProductionResultPage() {
const fetchWiList = useCallback(async () => {
setWiLoading(true);
try {
const params: Record<string, string> = {};
const params: Record<string, string> = { page: String(currentPage), pageSize: String(pageSize) };
for (const f of searchFilters) {
if (f.value) {
if (f.columnName === "progress_status") params.progressStatus = f.value;
@@ -145,6 +146,7 @@ export default function ProductionResultPage() {
}
const res = await apiClient.get("/work-instruction/list", { params });
const raw: any[] = res.data?.data || [];
const total: number = res.data?.totalCount ?? raw.length;
// work_instruction_no 기준 중복 제거 (detail JOIN으로 여러 행 반환)
const seen = new Set<string>();
@@ -167,15 +169,19 @@ export default function ProductionResultPage() {
};
});
setWiList(enriched);
setWiTotalCount(total);
} catch {
toast.error("작업지시 목록 조회 실패");
} finally {
setWiLoading(false);
}
}, [searchFilters]);
}, [searchFilters, currentPage, pageSize]);
useEffect(() => { fetchWiList(); }, [fetchWiList]);
// 검색 조건 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchFilters]);
// 실적 로드
useEffect(() => {
if (!selectedWiId) { setProcessData([]); return; }
@@ -237,13 +243,11 @@ export default function ProductionResultPage() {
return result;
}, [wiList, groupBy]);
// 페이지네이션 계산
const totalPages = Math.max(1, Math.ceil(wiList.length / pageSize));
// 페이지네이션 계산 (서버사이드)
const totalPages = Math.max(1, Math.ceil(wiTotalCount / pageSize));
const safePage = Math.min(Math.max(1, currentPage), totalPages);
const paginatedRows = useMemo(() => {
const start = (safePage - 1) * pageSize;
return wiList.slice(start, start + pageSize);
}, [wiList, safePage, pageSize]);
// 서버가 이미 페이지 분량만 반환하므로 slice 불필요
const paginatedRows = wiList;
const paginatedGroupedData = useMemo(() => {
if (groupBy === "none") return paginatedRows;
@@ -283,8 +287,7 @@ export default function ProductionResultPage() {
return pages;
};
// 필터 변경 시 페이지로 이동
useEffect(() => { setCurrentPage(1); }, [wiList.length]);
// (검색 조건 변경 시 1페이지 리셋은 위 useEffect에서 처리)
const toggleGroup = (key: string) => {
setExpandedGroups((prev) => {
@@ -337,7 +340,7 @@ export default function ProductionResultPage() {
tableName={WI_TABLE}
filterId="c16-production-result"
onFilterChange={setSearchFilters}
dataCount={wiList.length}
dataCount={wiTotalCount}
/>
{/* 메인 */}
@@ -351,7 +354,7 @@ export default function ProductionResultPage() {
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<Badge variant="secondary" className="font-mono text-xs">{wiList.length}</Badge>
<Badge variant="secondary" className="font-mono text-xs">{wiTotalCount}</Badge>
</div>
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
<SelectTrigger className="h-8 w-[120px] text-xs">
@@ -459,7 +462,7 @@ export default function ProductionResultPage() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-foreground">{wiList.length.toLocaleString()}</span>
<span className="font-medium text-foreground">{wiTotalCount.toLocaleString()}</span>
<span></span>
</div>
<div className="flex items-center gap-1.5">

View File

@@ -65,6 +65,10 @@ export default function WorkInstructionPage() {
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 서버사이드 페이지네이션 (WI 단위)
const [wiPage, setWiPage] = useState(1);
const [wiPageSize, setWiPageSize] = useState(20);
const [wiTotalCount, setWiTotalCount] = useState(0);
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
@@ -143,7 +147,7 @@ export default function WorkInstructionPage() {
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
const params: any = { page: wiPage, pageSize: wiPageSize };
for (const f of searchFilters) {
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
const [from, to] = f.value.split("|");
@@ -160,12 +164,18 @@ export default function WorkInstructionPage() {
}
}
const r = await getWorkInstructionList(params);
if (r.success) setOrders(r.data || []);
if (r.success) {
setOrders(r.data || []);
setWiTotalCount(r.totalCount ?? (r.data?.length || 0));
}
} catch {} finally { setLoading(false); }
}, [searchFilters]);
}, [searchFilters, wiPage, wiPageSize]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 검색 조건 변경 시 1페이지로 리셋
useEffect(() => { setWiPage(1); }, [searchFilters]);
// ─── 1단계 등록 ───
const openRegModal = () => {
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
@@ -520,6 +530,12 @@ export default function WorkInstructionPage() {
showPagination
draggableColumns
columnOrderKey="c16-work-instruction"
serverPagination
serverCurrentPage={wiPage}
serverPageSize={wiPageSize}
serverTotalCount={wiTotalCount}
onServerPageChange={setWiPage}
onServerPageSizeChange={(n) => { setWiPageSize(n); setWiPage(1); }}
/>
</div>
</div>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
@@ -1070,7 +1071,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1078,15 +1080,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -34,6 +34,7 @@ import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@@ -1151,12 +1152,12 @@ export default function ChunganSalesOrderPage() {
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1026,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1034,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1026,7 +1027,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1034,15 +1036,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
const DETAIL_TABLE = "sales_order_detail";
@@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>

View File

@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const MASTER_TABLE = "purchase_order_mng";
@@ -1038,7 +1039,8 @@ export default function PurchaseOrderPage() {
<div className="grid grid-cols-2 gap-3.5">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground tracking-wide"></Label>
<Select
<SmartSelect
options={categoryOptions["supplier_code"] || []}
value={masterForm.supplier_code || ""}
onValueChange={(v) => {
const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v);
@@ -1046,15 +1048,9 @@ export default function PurchaseOrderPage() {
setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name }));
recalcPrices(masterForm.price_mode || "", v);
}}
placeholder="공급업체 선택"
disabled={isReadOnly}
>
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["supplier_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>

View File

@@ -31,6 +31,7 @@ import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@@ -936,12 +937,12 @@ export default function JeilGlassOrderPage() {
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect
options={categoryOptions["partner_id"] || []}
value={masterForm.partner_id || ""}
onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}
placeholder="거래처 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>

View File

@@ -83,6 +83,15 @@ export interface EDataTableProps<T extends Record<string, any> = any> {
showPagination?: boolean;
defaultPageSize?: number;
// ─── 서버사이드 페이지네이션 모드 ───
// serverPagination=true 일 때: 내부 slice/filter/sort 미사용, data는 이미 해당 페이지 분량
serverPagination?: boolean;
serverCurrentPage?: number;
serverPageSize?: number;
serverTotalCount?: number;
onServerPageChange?: (page: number) => void;
onServerPageSizeChange?: (size: number) => void;
className?: string;
}
@@ -275,6 +284,12 @@ export function EDataTable<T extends Record<string, any> = any>({
showRowNumber = false,
showPagination = true,
defaultPageSize = 50,
serverPagination = false,
serverCurrentPage,
serverPageSize,
serverTotalCount,
onServerPageChange,
onServerPageSizeChange,
className,
}: EDataTableProps<T>) {
const [columns, setColumns] = useState(initialColumns);
@@ -287,10 +302,21 @@ export function EDataTable<T extends Record<string, any> = any>({
// 헤더 필터
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
// 페이지네이션 — 서버사이드 모드면 외부 state 사용
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
const currentPage = serverPagination ? (serverCurrentPage ?? 1) : internalCurrentPage;
const pageSize = serverPagination ? (serverPageSize ?? defaultPageSize) : internalPageSize;
const setCurrentPage = (next: number | ((prev: number) => number)) => {
const resolved = typeof next === "function" ? (next as (p: number) => number)(currentPage) : next;
if (serverPagination) onServerPageChange?.(resolved);
else setInternalCurrentPage(resolved);
};
const setPageSize = (n: number) => {
if (serverPagination) onServerPageSizeChange?.(n);
else setInternalPageSize(n);
};
const [pageSizeInput, setPageSizeInput] = useState(String(serverPagination ? (serverPageSize ?? defaultPageSize) : defaultPageSize));
// 그룹 접기/펼치기
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
@@ -394,8 +420,9 @@ export function EDataTable<T extends Record<string, any> = any>({
});
};
// 필터 + 정렬
// 필터 + 정렬 (서버사이드 모드면 원본 data 그대로 사용)
const processedData = useMemo(() => {
if (serverPagination) return data;
let result = [...data];
// 헤더 필터
@@ -425,24 +452,28 @@ export function EDataTable<T extends Record<string, any> = any>({
}
return result;
}, [data, headerFilters, sortState, onSortChange]);
}, [data, headerFilters, sortState, onSortChange, serverPagination]);
// 필터/데이터 건수 변경 시 1페이지 리셋 (참조만 바뀐 경우는 리셋 안 함)
useEffect(() => { setCurrentPage(1); }, [data.length, headerFilters]);
// 필터/데이터 건수 변경 시 1페이지 리셋 (서버사이드에선 외부가 제어)
useEffect(() => {
if (!serverPagination) setCurrentPage(1);
}, [data.length, headerFilters, serverPagination]);
// 페이지네이션
const totalItems = processedData.length;
const totalItems = serverPagination ? (serverTotalCount ?? data.length) : processedData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(currentPage, totalPages);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
if (!serverPagination && currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages, serverPagination]);
const pageOffset = (safePage - 1) * pageSize;
const paginatedDataRaw = showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
const paginatedDataRaw = serverPagination
? processedData
: showPagination
? processedData.slice(pageOffset, pageOffset + pageSize)
: processedData;
// 접힌 그룹의 데이터 행 숨김
const paginatedData = useMemo(() => {

View File

@@ -4,7 +4,7 @@ export interface PaginatedResponse { success: boolean; data: any[]; totalCount:
export async function getWorkInstructionList(params?: Record<string, any>) {
const res = await apiClient.get("/work-instruction/list", { params });
return res.data as { success: boolean; data: any[] };
return res.data as { success: boolean; data: any[]; totalCount?: number; page?: number; pageSize?: number };
}
export async function previewWorkInstructionNo() {