Merge pull request 'jskim-node' (#37) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/37
This commit is contained in:
@@ -149,6 +149,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import kpiRoutes from "./routes/kpiRoutes"; // KPI (TASK:ERP-022)
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
@@ -378,6 +379,7 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api/kpi", kpiRoutes); // KPI (TASK:ERP-022)
|
||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* KPI 컨트롤러 — TASK:ERP-022
|
||||
* 일별 생산량 등 KPI 지표 조회 전담
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* GET /api/kpi/daily-production?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
* 회사별 일별 생산량 조회
|
||||
*/
|
||||
export async function getDailyProduction(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const from = (req.query.from as string) || "";
|
||||
const to = (req.query.to as string) || "";
|
||||
|
||||
const params: any[] = [companyCode];
|
||||
let where = "company_code = $1";
|
||||
if (from) {
|
||||
params.push(from);
|
||||
where += ` AND prod_date >= $${params.length}`;
|
||||
}
|
||||
if (to) {
|
||||
params.push(to);
|
||||
where += ` AND prod_date <= $${params.length}`;
|
||||
}
|
||||
|
||||
const result = await getPool().query(
|
||||
`SELECT prod_date, production_qty, defect_qty, work_hours, remark
|
||||
FROM kpi_daily_production
|
||||
WHERE ${where}
|
||||
ORDER BY prod_date`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("KPI 일별 생산량 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -468,10 +468,10 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, execution_type, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer]
|
||||
);
|
||||
const newDetailId = insertRes.rows[0].id;
|
||||
|
||||
|
||||
@@ -14,13 +14,23 @@ export async function getOrderSummary(req: AuthenticatedRequest, res: Response)
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { excludePlanned, itemCode, itemName } = req.query;
|
||||
|
||||
const data = await productionService.getOrderSummary(companyCode, {
|
||||
// 서버 페이징 (size 미지정 시 기존 동작 유지: 전체 반환)
|
||||
const page = parseInt(String(req.query.page ?? "1"), 10) || 1;
|
||||
const size = parseInt(String(req.query.size ?? "0"), 10) || 0;
|
||||
|
||||
const result = await productionService.getOrderSummary(companyCode, {
|
||||
excludePlanned: excludePlanned === "true",
|
||||
itemCode: itemCode as string,
|
||||
itemName: itemName as string,
|
||||
page,
|
||||
size,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
// 페이징 사용 시 result는 { data, total, page, size, totalPages } 객체
|
||||
if (size > 0 && !Array.isArray(result)) {
|
||||
return res.json({ success: true, ...result });
|
||||
}
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("수주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
@@ -47,15 +57,23 @@ export async function getPlans(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { productType, status, startDate, endDate, itemCode } = req.query;
|
||||
|
||||
const data = await productionService.getPlans(companyCode, {
|
||||
const page = parseInt(String(req.query.page ?? "1"), 10) || 1;
|
||||
const size = parseInt(String(req.query.size ?? "0"), 10) || 0;
|
||||
|
||||
const result = await productionService.getPlans(companyCode, {
|
||||
productType: productType as string,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
itemCode: itemCode as string,
|
||||
page,
|
||||
size,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
if (size > 0 && !Array.isArray(result)) {
|
||||
return res.json({ success: true, ...result });
|
||||
}
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
|
||||
@@ -13,6 +13,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
// 서버 페이징 파라미터 (없으면 기존 동작 유지: 전체 조회)
|
||||
const page = parseInt(String(req.query.page ?? "1"), 10) || 1;
|
||||
const size = parseInt(String(req.query.size ?? "0"), 10) || 0;
|
||||
const usePaging = size > 0;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
@@ -89,10 +94,41 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
if (usePaging) {
|
||||
// total 카운트 — JOIN/GROUP 없이 si 기준 distinct count
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT si.id)::int AS total
|
||||
FROM shipment_instruction si
|
||||
LEFT JOIN customer_mng c
|
||||
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
|
||||
${where}
|
||||
`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = countResult.rows[0]?.total ?? 0;
|
||||
|
||||
const offset = (page - 1) * size;
|
||||
const pagedQuery = `${query} LIMIT $${idx} OFFSET $${idx + 1}`;
|
||||
const pagedResult = await pool.query(pagedQuery, [...params, size, offset]);
|
||||
|
||||
logger.info("출하지시 목록 조회 (페이징)", {
|
||||
companyCode, page, size, total, count: pagedResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: pagedResult.rows,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.max(1, Math.ceil(total / size)),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
return res.json({ success: true, data: result.rows, total: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
|
||||
@@ -151,6 +151,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
// 서버 페이징 파라미터 (없으면 기존 동작 유지: 전체 조회)
|
||||
const page = parseInt(String(req.query.page ?? "1"), 10) || 1;
|
||||
const size = parseInt(String(req.query.size ?? "0"), 10) || 0;
|
||||
const usePaging = size > 0;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -239,6 +244,53 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 서버 페이징 적용 시: COUNT + LIMIT/OFFSET
|
||||
if (usePaging) {
|
||||
const countQuery = `
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d
|
||||
ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m
|
||||
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code)
|
||||
AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code
|
||||
AND sp.company_code = c.company_code
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = countResult.rows[0]?.total ?? 0;
|
||||
|
||||
const offset = (page - 1) * size;
|
||||
const pagedQuery = `${query} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
||||
const pagedResult = await pool.query(pagedQuery, [...params, size, offset]);
|
||||
|
||||
logger.info("출하계획 목록 조회 (페이징)", {
|
||||
companyCode,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
rowCount: pagedResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: pagedResult.rows,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.max(1, Math.ceil(total / size)),
|
||||
});
|
||||
}
|
||||
|
||||
// 페이징 미사용: 기존 동작 (전체 조회)
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하계획 목록 조회", {
|
||||
@@ -246,7 +298,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
return res.json({ success: true, data: result.rows, total: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 목록 조회 실패", {
|
||||
error: error.message,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* KPI 라우트 — TASK:ERP-022
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/kpiController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/daily-production", ctrl.getDailyProduction);
|
||||
|
||||
export default router;
|
||||
@@ -15,7 +15,13 @@ import { logger } from "../utils/logger";
|
||||
|
||||
export async function getOrderSummary(
|
||||
companyCode: string,
|
||||
options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string }
|
||||
options?: {
|
||||
excludePlanned?: boolean;
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
) {
|
||||
const pool = getPool();
|
||||
const conditions: string[] = ["so.company_code = $1"];
|
||||
@@ -35,6 +41,10 @@ export async function getOrderSummary(
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const page = options?.page && options.page > 0 ? options.page : 1;
|
||||
const size = options?.size && options.size > 0 ? options.size : 0; // 0 = 전체 (하위호환)
|
||||
const usePaging = size > 0;
|
||||
|
||||
// 단일 쿼리로 요약 + 상세 + 재고 + 계획 통합 조회
|
||||
const query = `
|
||||
WITH all_orders AS (
|
||||
@@ -122,38 +132,113 @@ export async function getOrderSummary(
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
LEFT JOIN item_info_dedup ilt ON os.item_code = ilt.item_number
|
||||
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||
ORDER BY os.item_code;
|
||||
ORDER BY os.item_code
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
// 페이징 시: total 별도 산출 + LIMIT/OFFSET 적용
|
||||
let total = 0;
|
||||
let pagedQuery = `${query};`;
|
||||
let pagedParams: any[] = params;
|
||||
|
||||
// 상세 데이터: all_orders CTE와 동일 로직 (쿼리 재사용 위해 별도 실행)
|
||||
const detailQuery = `
|
||||
SELECT id::text, order_no, part_code, part_name,
|
||||
COALESCE(order_qty::numeric, 0) AS order_qty,
|
||||
COALESCE(ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(balance_qty::numeric, 0) AS balance_qty,
|
||||
due_date, status, partner_id, manager_name
|
||||
FROM sales_order_mng
|
||||
WHERE ${conditions.map(c => c.replace(/so\./g, "")).join(" AND ")}
|
||||
AND part_code IS NOT NULL AND part_code != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code
|
||||
if (usePaging) {
|
||||
// total: 그룹 수 = order_summary CTE의 행 수
|
||||
const countQuery = `
|
||||
WITH all_orders AS (
|
||||
SELECT so.part_code, so.company_code
|
||||
FROM sales_order_mng so
|
||||
WHERE ${whereClause}
|
||||
AND so.part_code IS NOT NULL AND so.part_code != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
)
|
||||
UNION ALL
|
||||
SELECT sd.part_code, sd.company_code
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
)
|
||||
UNION ALL
|
||||
SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name,
|
||||
COALESCE(sd.qty::numeric, 0) AS order_qty,
|
||||
COALESCE(sd.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty,
|
||||
sd.due_date::date, so.status, so.partner_id, so.manager_name
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
ORDER BY part_code, due_date;
|
||||
`;
|
||||
const detailResult = await pool.query(detailQuery, params);
|
||||
SELECT COUNT(*)::int AS total FROM (
|
||||
SELECT DISTINCT part_code FROM all_orders
|
||||
) g;
|
||||
`;
|
||||
const countRes = await pool.query(countQuery, params);
|
||||
total = countRes.rows[0]?.total ?? 0;
|
||||
|
||||
const offset = (page - 1) * size;
|
||||
pagedQuery = `${query} LIMIT $${paramIdx} OFFSET $${paramIdx + 1};`;
|
||||
pagedParams = [...params, size, offset];
|
||||
}
|
||||
|
||||
const result = await pool.query(pagedQuery, pagedParams);
|
||||
|
||||
// 상세 데이터: 페이징 시 현재 페이지의 part_codes만, 미페이징 시 전체
|
||||
let detailQuery: string;
|
||||
let detailParams: any[];
|
||||
|
||||
if (usePaging) {
|
||||
const partCodes = result.rows.map((r: any) => r.item_code).filter(Boolean);
|
||||
if (partCodes.length === 0) {
|
||||
const data = result.rows.map((g: any) => ({ ...g, orders: [] }));
|
||||
return { data, total, page, size, totalPages: Math.max(1, Math.ceil(total / size)) };
|
||||
}
|
||||
detailQuery = `
|
||||
SELECT id::text, order_no, part_code, part_name,
|
||||
COALESCE(order_qty::numeric, 0) AS order_qty,
|
||||
COALESCE(ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(balance_qty::numeric, 0) AS balance_qty,
|
||||
due_date, status, partner_id, manager_name
|
||||
FROM sales_order_mng
|
||||
WHERE company_code = $1
|
||||
AND part_code = ANY($2::text[])
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code
|
||||
)
|
||||
UNION ALL
|
||||
SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name,
|
||||
COALESCE(sd.qty::numeric, 0) AS order_qty,
|
||||
COALESCE(sd.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty,
|
||||
sd.due_date::date, so.status, so.partner_id, so.manager_name
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code = ANY($2::text[])
|
||||
ORDER BY part_code, due_date;
|
||||
`;
|
||||
detailParams = [companyCode, partCodes];
|
||||
} else {
|
||||
detailQuery = `
|
||||
SELECT id::text, order_no, part_code, part_name,
|
||||
COALESCE(order_qty::numeric, 0) AS order_qty,
|
||||
COALESCE(ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(balance_qty::numeric, 0) AS balance_qty,
|
||||
due_date, status, partner_id, manager_name
|
||||
FROM sales_order_mng
|
||||
WHERE ${conditions.map(c => c.replace(/so\./g, "")).join(" AND ")}
|
||||
AND part_code IS NOT NULL AND part_code != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sales_order_detail sd
|
||||
WHERE sd.order_no = sales_order_mng.order_no AND sd.company_code = sales_order_mng.company_code
|
||||
)
|
||||
UNION ALL
|
||||
SELECT sd.id::text, sd.order_no, sd.part_code, sd.part_name,
|
||||
COALESCE(sd.qty::numeric, 0) AS order_qty,
|
||||
COALESCE(sd.ship_qty::numeric, 0) AS ship_qty,
|
||||
COALESCE(sd.balance_qty::numeric, COALESCE(sd.qty::numeric, 0) - COALESCE(sd.ship_qty::numeric, 0), 0) AS balance_qty,
|
||||
sd.due_date::date, so.status, so.partner_id, so.manager_name
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
ORDER BY part_code, due_date;
|
||||
`;
|
||||
detailParams = params;
|
||||
}
|
||||
|
||||
const detailResult = await pool.query(detailQuery, detailParams);
|
||||
|
||||
// 그룹별로 상세 데이터 매핑
|
||||
const ordersByItem: Record<string, any[]> = {};
|
||||
@@ -168,7 +253,18 @@ export async function getOrderSummary(
|
||||
orders: ordersByItem[group.item_code || "__null__"] || [],
|
||||
}));
|
||||
|
||||
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length });
|
||||
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length, page, size, total });
|
||||
|
||||
if (usePaging) {
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.max(1, Math.ceil(total / size)),
|
||||
};
|
||||
}
|
||||
// 하위호환: 페이징 미사용 시 기존 형태 (배열 그대로) 유지
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -210,6 +306,8 @@ export async function getPlans(
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemCode?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
) {
|
||||
const pool = getPool();
|
||||
@@ -217,6 +315,10 @@ export async function getPlans(
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
const page = options?.page && options.page > 0 ? options.page : 1;
|
||||
const size = options?.size && options.size > 0 ? options.size : 0;
|
||||
const usePaging = size > 0;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
// 일반 회사: 자사 데이터만
|
||||
} else {
|
||||
@@ -269,6 +371,26 @@ export async function getPlans(
|
||||
ORDER BY p.start_date ASC, p.item_code ASC
|
||||
`;
|
||||
|
||||
if (usePaging) {
|
||||
const countQuery = `SELECT COUNT(*)::int AS total FROM production_plan_mng p ${whereClause}`;
|
||||
const countRes = await pool.query(countQuery, params);
|
||||
const total = countRes.rows[0]?.total ?? 0;
|
||||
|
||||
const offset = (page - 1) * size;
|
||||
const pagedQuery = `${query} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
||||
const pagedRes = await pool.query(pagedQuery, [...params, size, offset]);
|
||||
|
||||
logger.info("생산계획 목록 조회 (페이징)", { companyCode, page, size, total });
|
||||
|
||||
return {
|
||||
data: pagedRes.rows,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.max(1, Math.ceil(total / size)),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
|
||||
return result.rows;
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
@@ -282,6 +283,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormExecutionType("serial");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
@@ -309,6 +311,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_16)
|
||||
type Row = {
|
||||
prod_date: string;
|
||||
production_qty: number;
|
||||
defect_qty: number;
|
||||
work_hours: number;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
function defaultRange() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const start = new Date(y, m, 1);
|
||||
const end = new Date(y, m + 1, 0);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return { from: fmt(start), to: fmt(end) };
|
||||
}
|
||||
|
||||
export default function KpiDailyProductionPage() {
|
||||
const init = defaultRange();
|
||||
const [from, setFrom] = useState(init.from);
|
||||
const [to, setTo] = useState(init.to);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!from || !to) {
|
||||
toast.error("기간을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } });
|
||||
const data: Row[] = res.data?.data || [];
|
||||
setRows(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return rows.map((r) => {
|
||||
const d = new Date(r.prod_date);
|
||||
const label = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
const hourly = Number(r.work_hours) > 0
|
||||
? Number(r.production_qty) / Number(r.work_hours)
|
||||
: 0;
|
||||
return {
|
||||
label,
|
||||
production_qty: Number(r.production_qty) || 0,
|
||||
work_hours: Number(r.work_hours) || 0,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0);
|
||||
const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0);
|
||||
const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0);
|
||||
const hourly = totalHours > 0 ? totalQty / totalHours : 0;
|
||||
return {
|
||||
totalQty,
|
||||
totalHours: Math.round(totalHours * 100) / 100,
|
||||
totalDefect,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const columns = useMemo<EDataTableColumn[]>(() => [
|
||||
{ key: "prod_date", label: "일자", width: "120px",
|
||||
render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" },
|
||||
{ key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true },
|
||||
{ key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "work_hours", label: "작업시간(h)", width: "120px", align: "right",
|
||||
render: (_v, row) => Number(row.work_hours || 0).toFixed(2) },
|
||||
{ key: "hourly", label: "시간당 생산량", width: "140px", align: "right",
|
||||
render: (_v, row) => {
|
||||
const wh = Number(row.work_hours) || 0;
|
||||
if (wh === 0) return "-";
|
||||
const v = Number(row.production_qty) / wh;
|
||||
return v.toFixed(2);
|
||||
} },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3 overflow-hidden">
|
||||
<div className="shrink-0 flex items-end gap-2 flex-wrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">시작일</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 w-40" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">종료일</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-9 w-40" />
|
||||
</div>
|
||||
<Button onClick={fetchData} disabled={loading} className="h-9">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Search className="h-4 w-4 mr-1" />조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 grid grid-cols-4 gap-3">
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 생산수량</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalQty.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 작업시간 (h)</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalHours.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 불량수량</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalDefect.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3 bg-primary/5 border-primary/20">
|
||||
<div className="text-xs text-muted-foreground">시간당 평균 생산량</div>
|
||||
<div className="text-2xl font-bold mt-1 text-primary">{summary.hourly.toLocaleString()}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shrink-0 p-3">
|
||||
<div className="text-sm font-semibold mb-2">일별 생산량 / 작업시간</div>
|
||||
<div style={{ width: "100%", height: 320 }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar yAxisId="left" dataKey="production_qty" name="생산수량" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="work_hours" name="작업시간(h)" stroke="#f97316" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
|
||||
<EDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
emptyMessage="기간 내 데이터가 없어요"
|
||||
showPagination
|
||||
draggableColumns={false}
|
||||
rowKey={(r: any) => r.prod_date}
|
||||
columnOrderKey="c16-kpi-daily-production"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ItemRoutingTab() {
|
||||
const [formRequired, setFormRequired] = useState("Y");
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
@@ -281,6 +282,7 @@ export function ItemRoutingTab() {
|
||||
setFormRequired("Y");
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormExecutionType("serial");
|
||||
setFormStandardTime("");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
@@ -308,6 +310,7 @@ export function ItemRoutingTab() {
|
||||
setFormRequired(row.is_required === "N" ? "N" : "Y");
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -182,13 +182,55 @@ export default function ProductionResultPage() {
|
||||
const load = async () => {
|
||||
setProcessLoading(true);
|
||||
try {
|
||||
// 1) 공정 마스터 조회
|
||||
const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "seq_no", order: "asc" },
|
||||
});
|
||||
setProcessData(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
const wops: any[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
|
||||
// 2) 공정별 실적(work_order_process_result) 합계 조인 — 화면이 양품/불량/입력 합계를 보여주려면 result를 별도 집계해 매핑해야 함
|
||||
if (wops.length > 0) {
|
||||
const wopIds = wops.map((w) => w.id);
|
||||
try {
|
||||
const wr = await apiClient.post(`/table-management/tables/work_order_process_result/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "wop_id", operator: "in", value: wopIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const results: any[] = wr.data?.data?.data || wr.data?.data?.rows || [];
|
||||
const agg = new Map<string, { good: number; defect: number; input: number; firstStart: string | null; lastEnd: string | null; statuses: Set<string> }>();
|
||||
for (const r of results) {
|
||||
const cur = agg.get(r.wop_id) || { good: 0, defect: 0, input: 0, firstStart: null, lastEnd: null, statuses: new Set<string>() };
|
||||
cur.good += Number(r.good_qty) || 0;
|
||||
cur.defect += Number(r.defect_qty) || 0;
|
||||
cur.input += Number(r.input_qty) || 0;
|
||||
if (r.started_at && (!cur.firstStart || String(r.started_at) < cur.firstStart)) cur.firstStart = String(r.started_at);
|
||||
if (r.completed_at && (!cur.lastEnd || String(r.completed_at) > cur.lastEnd)) cur.lastEnd = String(r.completed_at);
|
||||
if (r.status) cur.statuses.add(String(r.status));
|
||||
agg.set(r.wop_id, cur);
|
||||
}
|
||||
const enriched = wops.map((w) => {
|
||||
const a = agg.get(w.id);
|
||||
return {
|
||||
...w,
|
||||
good_qty: a?.good ?? 0,
|
||||
defect_qty: a?.defect ?? 0,
|
||||
input_qty: a?.input ?? 0,
|
||||
started_at: a?.firstStart ?? null,
|
||||
completed_at: a?.lastEnd ?? null,
|
||||
result_status: a?.statuses.has("completed") ? "completed" : (a?.statuses.values().next().value ?? null),
|
||||
};
|
||||
});
|
||||
setProcessData(enriched);
|
||||
} catch {
|
||||
setProcessData(wops);
|
||||
}
|
||||
} else {
|
||||
setProcessData([]);
|
||||
}
|
||||
} catch { setProcessData([]); }
|
||||
finally { setProcessLoading(false); }
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -453,11 +454,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -465,7 +468,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1046,12 +1049,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ItemRoutingTab() {
|
||||
const [formRequired, setFormRequired] = useState("Y");
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
@@ -281,6 +282,7 @@ export function ItemRoutingTab() {
|
||||
setFormRequired("Y");
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormExecutionType("serial");
|
||||
setFormStandardTime("");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
@@ -308,6 +310,7 @@ export function ItemRoutingTab() {
|
||||
setFormRequired(row.is_required === "N" ? "N" : "Y");
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// TASK:ERP-022 — KPI 일별 생산량 (COMPANY_30)
|
||||
type Row = {
|
||||
prod_date: string;
|
||||
production_qty: number;
|
||||
defect_qty: number;
|
||||
work_hours: number;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
// 기본 기간: 이번 달 1일 ~ 말일
|
||||
function defaultRange() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const start = new Date(y, m, 1);
|
||||
const end = new Date(y, m + 1, 0);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return { from: fmt(start), to: fmt(end) };
|
||||
}
|
||||
|
||||
export default function KpiDailyProductionPage() {
|
||||
const init = defaultRange();
|
||||
const [from, setFrom] = useState(init.from);
|
||||
const [to, setTo] = useState(init.to);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!from || !to) {
|
||||
toast.error("기간을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/kpi/daily-production`, { params: { from, to } });
|
||||
const data: Row[] = res.data?.data || [];
|
||||
setRows(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 차트용 데이터: 일자만 짧게 (M/D)
|
||||
const chartData = useMemo(() => {
|
||||
return rows.map((r) => {
|
||||
const d = new Date(r.prod_date);
|
||||
const label = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
const hourly = Number(r.work_hours) > 0
|
||||
? Number(r.production_qty) / Number(r.work_hours)
|
||||
: 0;
|
||||
return {
|
||||
label,
|
||||
production_qty: Number(r.production_qty) || 0,
|
||||
work_hours: Number(r.work_hours) || 0,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
// 합계
|
||||
const summary = useMemo(() => {
|
||||
const totalQty = rows.reduce((a, r) => a + (Number(r.production_qty) || 0), 0);
|
||||
const totalHours = rows.reduce((a, r) => a + (Number(r.work_hours) || 0), 0);
|
||||
const totalDefect = rows.reduce((a, r) => a + (Number(r.defect_qty) || 0), 0);
|
||||
const hourly = totalHours > 0 ? totalQty / totalHours : 0;
|
||||
return {
|
||||
totalQty,
|
||||
totalHours: Math.round(totalHours * 100) / 100,
|
||||
totalDefect,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
// 테이블 컬럼
|
||||
const columns = useMemo<EDataTableColumn[]>(() => [
|
||||
{ key: "prod_date", label: "일자", width: "120px",
|
||||
render: (_v, row) => row.prod_date ? String(row.prod_date).slice(0, 10) : "-" },
|
||||
{ key: "production_qty", label: "생산수량", width: "120px", align: "right", formatNumber: true },
|
||||
{ key: "defect_qty", label: "불량수량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "work_hours", label: "작업시간(h)", width: "120px", align: "right",
|
||||
render: (_v, row) => Number(row.work_hours || 0).toFixed(2) },
|
||||
{ key: "hourly", label: "시간당 생산량", width: "140px", align: "right",
|
||||
render: (_v, row) => {
|
||||
const wh = Number(row.work_hours) || 0;
|
||||
if (wh === 0) return "-";
|
||||
const v = Number(row.production_qty) / wh;
|
||||
return v.toFixed(2);
|
||||
} },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3 overflow-hidden">
|
||||
{/* 필터 바 */}
|
||||
<div className="shrink-0 flex items-end gap-2 flex-wrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">시작일</Label>
|
||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 w-40" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">종료일</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-9 w-40" />
|
||||
</div>
|
||||
<Button onClick={fetchData} disabled={loading} className="h-9">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Search className="h-4 w-4 mr-1" />조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="shrink-0 grid grid-cols-4 gap-3">
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 생산수량</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalQty.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 작업시간 (h)</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalHours.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-muted-foreground">총 불량수량</div>
|
||||
<div className="text-2xl font-bold mt-1">{summary.totalDefect.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card className="p-3 bg-primary/5 border-primary/20">
|
||||
<div className="text-xs text-muted-foreground">시간당 평균 생산량</div>
|
||||
<div className="text-2xl font-bold mt-1 text-primary">{summary.hourly.toLocaleString()}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<Card className="shrink-0 p-3">
|
||||
<div className="text-sm font-semibold mb-2">일별 생산량 / 작업시간</div>
|
||||
<div style={{ width: "100%", height: 320 }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar yAxisId="left" dataKey="production_qty" name="생산수량" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="work_hours" name="작업시간(h)" stroke="#f97316" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
|
||||
<EDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
emptyMessage="기간 내 데이터가 없어요"
|
||||
showPagination
|
||||
draggableColumns={false}
|
||||
rowKey={(r: any) => r.prod_date}
|
||||
columnOrderKey="c30-kpi-daily-production"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,6 +151,27 @@ export default function EquipmentMonitoringPage() {
|
||||
const [filterStatus, setFilterStatus] = useState<OperationStatus | "all">("all");
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
|
||||
// 기간 필터 (기본: 오늘)
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const [dateFrom, setDateFrom] = useState<string>(todayStr);
|
||||
const [dateTo, setDateTo] = useState<string>(todayStr);
|
||||
const setRangeToday = () => { const t = new Date().toISOString().slice(0, 10); setDateFrom(t); setDateTo(t); };
|
||||
const setRangeThisWeek = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay() || 7;
|
||||
const mon = new Date(now); mon.setDate(now.getDate() - (day - 1));
|
||||
const sun = new Date(mon); sun.setDate(mon.getDate() + 6);
|
||||
setDateFrom(mon.toISOString().slice(0, 10));
|
||||
setDateTo(sun.toISOString().slice(0, 10));
|
||||
};
|
||||
const setRangeThisMonth = () => {
|
||||
const now = new Date();
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
setDateFrom(first.toISOString().slice(0, 10));
|
||||
setDateTo(last.toISOString().slice(0, 10));
|
||||
};
|
||||
|
||||
// autoRefreshRef 동기화
|
||||
useEffect(() => {
|
||||
autoRefreshRef.current = autoRefresh;
|
||||
@@ -167,16 +188,27 @@ export default function EquipmentMonitoringPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [equipRes, wiRes, procRes] = await Promise.all([
|
||||
// 설비 마스터 (기간 무관, 전체)
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
autoFilter: true,
|
||||
page: 1,
|
||||
size: 500,
|
||||
}),
|
||||
apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })),
|
||||
// 작업지시 (시작일 기준 기간)
|
||||
apiClient.get("/work-instruction/list", { params: { dateFrom, dateTo } })
|
||||
.catch(() => ({ data: { data: [] } })),
|
||||
// 작업공정 (생성일 기준 기간)
|
||||
apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1,
|
||||
size: 2000,
|
||||
autoFilter: true,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
{ columnName: "created_date", operator: "greater_or_equal", value: `${dateFrom} 00:00:00` },
|
||||
{ columnName: "created_date", operator: "less_or_equal", value: `${dateTo} 23:59:59` },
|
||||
],
|
||||
},
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
|
||||
@@ -193,7 +225,7 @@ export default function EquipmentMonitoringPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -384,9 +416,9 @@ export default function EquipmentMonitoringPage() {
|
||||
/* ────────────── 렌더 ────────────── */
|
||||
|
||||
return (
|
||||
<div className={cn("min-h-screen space-y-5 p-4 md:p-6", theme.root)} style={theme.cssVars}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-5 overflow-hidden p-4 md:p-6", theme.root)} style={theme.cssVars}>
|
||||
{/* ── 헤더 ── */}
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-1.5 rounded-full bg-amber-400" />
|
||||
<h1 className={cn("text-2xl font-bold tracking-tight", theme.headerText)}>설비운영모니터링</h1>
|
||||
@@ -443,8 +475,21 @@ export default function EquipmentMonitoringPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 기간 필터 ── */}
|
||||
<div className={cn("flex shrink-0 items-center gap-2 rounded-lg border p-3", theme.card, theme.cardBorder)}>
|
||||
<span className={cn("text-sm font-semibold", theme.headerText)}>조회 기간</span>
|
||||
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<span className={cn("text-sm", theme.mutedText)}>~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<Button variant="outline" size="sm" onClick={setRangeToday}>오늘</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisWeek}>이번주</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisMonth}>이번달</Button>
|
||||
</div>
|
||||
|
||||
{/* ── 요약 카드 5개 ── */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{summaryCards.map((card) => (
|
||||
<button
|
||||
key={card.label}
|
||||
@@ -468,7 +513,7 @@ export default function EquipmentMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* ── 필터 pill ── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{filterPills.map((pill) => (
|
||||
<button
|
||||
key={pill.value}
|
||||
@@ -486,7 +531,8 @@ export default function EquipmentMonitoringPage() {
|
||||
<span className="text-muted-foreground ml-auto self-center text-sm">{filteredEquipments.length}대 표시</span>
|
||||
</div>
|
||||
|
||||
{/* ── 로딩 ── */}
|
||||
{/* ── 스크롤 영역: 로딩 / 데이터 없음 / 설비 카드 그리드 ── */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{loading && equipments.length === 0 && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
@@ -494,7 +540,6 @@ export default function EquipmentMonitoringPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 데이터 없음 ── */}
|
||||
{!loading && equipments.length === 0 && (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
<Inbox className="mb-3 h-12 w-12" />
|
||||
@@ -502,7 +547,6 @@ export default function EquipmentMonitoringPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 설비 카드 그리드 ── */}
|
||||
{filteredEquipments.length > 0 && (
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))" }}>
|
||||
{filteredEquipments.map((eq) => {
|
||||
@@ -661,6 +705,7 @@ export default function EquipmentMonitoringPage() {
|
||||
<p>해당 상태의 설비가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,27 @@ export default function ProductionMonitoringPage() {
|
||||
const [autoRefresh, setAutoRefresh] = useState(settings.autoRefresh);
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("전체");
|
||||
|
||||
// ─── 기간 필터 (기본: 오늘) ──────────────────────────────────
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const [dateFrom, setDateFrom] = useState<string>(todayStr);
|
||||
const [dateTo, setDateTo] = useState<string>(todayStr);
|
||||
const setRangeToday = () => { const t = new Date().toISOString().slice(0, 10); setDateFrom(t); setDateTo(t); };
|
||||
const setRangeThisWeek = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay() || 7; // Sun=0 → 7
|
||||
const mon = new Date(now); mon.setDate(now.getDate() - (day - 1));
|
||||
const sun = new Date(mon); sun.setDate(mon.getDate() + 6);
|
||||
setDateFrom(mon.toISOString().slice(0, 10));
|
||||
setDateTo(sun.toISOString().slice(0, 10));
|
||||
};
|
||||
const setRangeThisMonth = () => {
|
||||
const now = new Date();
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
setDateFrom(first.toISOString().slice(0, 10));
|
||||
setDateTo(last.toISOString().slice(0, 10));
|
||||
};
|
||||
|
||||
// ─── 실시간 시계 ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
@@ -131,8 +152,10 @@ export default function ProductionMonitoringPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 작업지시 목록 조회
|
||||
const wiRes = await apiClient.get("/work-instruction/list");
|
||||
// 작업지시 목록 조회 — 기간(시작일) 필터 적용
|
||||
const wiRes = await apiClient.get("/work-instruction/list", {
|
||||
params: { dateFrom, dateTo },
|
||||
});
|
||||
const wiRaw: WorkInstruction[] = wiRes.data?.success && Array.isArray(wiRes.data.data) ? wiRes.data.data : [];
|
||||
// 작업지시번호 기준 중복 제거 (디테일 JOIN으로 중복 발생 가능)
|
||||
const seen = new Set<string>();
|
||||
@@ -144,12 +167,19 @@ export default function ProductionMonitoringPage() {
|
||||
});
|
||||
setWorkInstructions(wiData);
|
||||
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시)
|
||||
// 공정현황 조회 (실패해도 작업지시는 표시) — 생성일(created_date) 기준 기간 필터
|
||||
try {
|
||||
const procRes = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
{ columnName: "created_date", operator: "greater_or_equal", value: `${dateFrom} 00:00:00` },
|
||||
{ columnName: "created_date", operator: "less_or_equal", value: `${dateTo} 23:59:59` },
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows: ProcessStep[] = procRes.data?.data?.data || procRes.data?.data?.rows || procRes.data?.data || [];
|
||||
|
||||
@@ -195,7 +225,7 @@ export default function ProductionMonitoringPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -241,9 +271,9 @@ export default function ProductionMonitoringPage() {
|
||||
|
||||
// ─── 렌더링 ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-auto p-4", theme.root)} style={theme.cssVars}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col gap-4 overflow-hidden p-4", theme.root)} style={theme.cssVars}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<h1 className={cn("text-2xl font-bold", theme.headerText)}>생산모니터링</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("flex items-center gap-1.5 text-sm", theme.mutedText)}>
|
||||
@@ -275,8 +305,21 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기간 필터 */}
|
||||
<div className={cn("flex shrink-0 items-center gap-2 rounded-lg border p-3", theme.card, theme.cardBorder)}>
|
||||
<span className={cn("text-sm font-semibold", theme.headerText)}>조회 기간</span>
|
||||
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<span className={cn("text-sm", theme.mutedText)}>~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<Button variant="outline" size="sm" onClick={setRangeToday}>오늘</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisWeek}>이번주</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisMonth}>이번달</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid flex-shrink-0 grid-cols-4 gap-4">
|
||||
<div className="grid shrink-0 grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
icon={<Timer className="h-5 w-5" />}
|
||||
label="대기중"
|
||||
@@ -304,7 +347,7 @@ export default function ProductionMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 탭 필터 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{(["전체", "대기", "진행중", "완료"] as FilterTab[]).map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
@@ -327,6 +370,8 @@ export default function ProductionMonitoringPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 — 로딩 / 빈 상태 / 작업 카드 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{/* 로딩 상태 */}
|
||||
{loading && workInstructions.length === 0 && (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-20">
|
||||
@@ -349,7 +394,7 @@ export default function ProductionMonitoringPage() {
|
||||
{filteredInstructions.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 gap-4",
|
||||
"gap-4",
|
||||
settings.layout === "grid" && "grid",
|
||||
settings.layout === "list" && "flex flex-col",
|
||||
settings.layout === "split" && "grid grid-cols-2",
|
||||
@@ -369,6 +414,7 @@ export default function ProductionMonitoringPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,27 @@ export default function QualityMonitoringPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 기간 필터 (기본: 오늘)
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const [dateFrom, setDateFrom] = useState<string>(todayStr);
|
||||
const [dateTo, setDateTo] = useState<string>(todayStr);
|
||||
const setRangeToday = () => { const t = new Date().toISOString().slice(0, 10); setDateFrom(t); setDateTo(t); };
|
||||
const setRangeThisWeek = () => {
|
||||
const now = new Date();
|
||||
const day = now.getDay() || 7;
|
||||
const mon = new Date(now); mon.setDate(now.getDate() - (day - 1));
|
||||
const sun = new Date(mon); sun.setDate(mon.getDate() + 6);
|
||||
setDateFrom(mon.toISOString().slice(0, 10));
|
||||
setDateTo(sun.toISOString().slice(0, 10));
|
||||
};
|
||||
const setRangeThisMonth = () => {
|
||||
const now = new Date();
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
setDateFrom(first.toISOString().slice(0, 10));
|
||||
setDateTo(last.toISOString().slice(0, 10));
|
||||
};
|
||||
|
||||
/* ───── 시계 ───── */
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
@@ -98,7 +119,16 @@ export default function QualityMonitoringPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", { autoFilter: true });
|
||||
const res = await apiClient.post("/table-management/tables/work_order_process/data", {
|
||||
autoFilter: true,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
{ columnName: "created_date", operator: "greater_or_equal", value: `${dateFrom} 00:00:00` },
|
||||
{ columnName: "created_date", operator: "less_or_equal", value: `${dateTo} 23:59:59` },
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows: ProcessRow[] = res.data?.data?.rows ?? res.data?.rows ?? [];
|
||||
setProcessData(rows);
|
||||
} catch (err) {
|
||||
@@ -106,7 +136,7 @@ export default function QualityMonitoringPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -265,9 +295,22 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* ── 본문 ── */}
|
||||
<div className="flex-1 space-y-6 overflow-auto p-6">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-hidden p-6">
|
||||
{/* 기간 필터 */}
|
||||
<div className={cn("flex shrink-0 items-center gap-2 rounded-lg border p-3", theme.card, theme.cardBorder)}>
|
||||
<span className={cn("text-sm font-semibold", theme.headerText)}>조회 기간</span>
|
||||
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<span className={cn("text-sm", theme.mutedText)}>~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className={cn("h-8 rounded border px-2 text-sm", theme.cardBorder, theme.card, theme.text)} />
|
||||
<Button variant="outline" size="sm" onClick={setRangeToday}>오늘</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisWeek}>이번주</Button>
|
||||
<Button variant="outline" size="sm" onClick={setRangeThisMonth}>이번달</Button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="grid shrink-0 grid-cols-5 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.label} className={cn("rounded-xl bg-gradient-to-br p-5 shadow-md", card.color)}>
|
||||
<p className="text-sm font-medium text-white/80">{card.label}</p>
|
||||
@@ -280,7 +323,7 @@ export default function QualityMonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* 검사유형 탭 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -297,8 +340,8 @@ export default function QualityMonitoringPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className={cn("overflow-hidden rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 테이블 영역 (스크롤) */}
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col overflow-auto rounded-xl border shadow", theme.card, theme.cardBorder)}>
|
||||
{/* 입고/출하 준비중 */}
|
||||
{activeTab === "incoming" || activeTab === "shipping" ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-gray-400">
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
@@ -282,6 +283,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormExecutionType("serial");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
@@ -309,6 +311,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -103,6 +103,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -142,7 +147,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -158,18 +163,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -485,7 +497,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -494,7 +506,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -560,6 +572,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -72,6 +72,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -84,7 +89,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -102,19 +107,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -212,7 +222,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -227,7 +237,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -268,7 +278,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
@@ -282,6 +283,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormExecutionType("serial");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
@@ -309,6 +311,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -36,6 +36,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -103,7 +104,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -159,6 +160,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -268,7 +270,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -488,11 +490,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -500,7 +504,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1090,12 +1094,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1290,17 +1295,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
@@ -282,6 +283,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormExecutionType("serial");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
@@ -309,6 +311,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,10 +139,11 @@ export default function ProductionPlanManagementPage() {
|
||||
|
||||
// 데이터 상태
|
||||
const [orderItems, setOrderItems] = useState<OrderSummaryItem[]>([]);
|
||||
// 좌측 수주목록 페이지네이션
|
||||
// 좌측 수주목록 페이지네이션 (서버 페이징)
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(20);
|
||||
const [orderPageSizeInput, setOrderPageSizeInput] = useState("20");
|
||||
const [orderTotalCount, setOrderTotalCount] = useState(0);
|
||||
const [stockItems, setStockItems] = useState<StockShortageItem[]>([]);
|
||||
const [finishedPlans, setFinishedPlans] = useState<ProductionPlan[]>([]);
|
||||
const [semiPlans, setSemiPlans] = useState<ProductionPlan[]>([]);
|
||||
@@ -210,22 +211,25 @@ export default function ProductionPlanManagementPage() {
|
||||
const res = await getOrderSummary({
|
||||
excludePlanned: filterUnplannedOrdersOnly,
|
||||
itemCode: searchItemCode || undefined,
|
||||
page: orderPage,
|
||||
size: orderPageSize,
|
||||
});
|
||||
if (res.success) setOrderItems(res.data || []);
|
||||
if (res.success) {
|
||||
setOrderItems(res.data || []);
|
||||
setOrderTotalCount(res.total ?? res.data?.length ?? 0);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수주 데이터 조회 실패: " + (err.message || ""));
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
}, [filterUnplannedOrdersOnly, searchItemCode, orderPage, orderPageSize]);
|
||||
|
||||
// 수주목록 페이지네이션 계산
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderItems.length / orderPageSize));
|
||||
const orderTotalPages = Math.max(1, Math.ceil(orderTotalCount / orderPageSize));
|
||||
const orderSafePage = Math.min(Math.max(1, orderPage), orderTotalPages);
|
||||
const paginatedOrderItems = useMemo(() => {
|
||||
const start = (orderSafePage - 1) * orderPageSize;
|
||||
return orderItems.slice(start, start + orderPageSize);
|
||||
}, [orderItems, orderSafePage, orderPageSize]);
|
||||
// 서버 페이징: 응답 자체가 페이지 데이터이므로 slice 불필요
|
||||
const paginatedOrderItems = orderItems;
|
||||
|
||||
const applyOrderPageSize = () => {
|
||||
const n = parseInt(orderPageSizeInput, 10);
|
||||
@@ -247,8 +251,8 @@ export default function ProductionPlanManagementPage() {
|
||||
return pages;
|
||||
};
|
||||
|
||||
// orderItems 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [orderItems.length]);
|
||||
// 검색 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setOrderPage(1); }, [filterUnplannedOrdersOnly, searchItemCode]);
|
||||
|
||||
const fetchStockShortage = useCallback(async () => {
|
||||
setLoadingStock(true);
|
||||
@@ -265,19 +269,28 @@ export default function ProductionPlanManagementPage() {
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
// 타임라인 성능: 기간 필터 미입력 시 기본 오늘 ~ +60일 자동 적용
|
||||
// 이전 기록은 검색에서 시작일/종료일을 직접 지정하면 조회됨
|
||||
const today = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const defaultStart = fmt(today);
|
||||
const defaultEnd = fmt(new Date(today.getTime() + 60 * 86400000));
|
||||
const effectiveStart = searchStartDate || defaultStart;
|
||||
const effectiveEnd = searchEndDate || defaultEnd;
|
||||
|
||||
const [finRes, semiRes] = await Promise.all([
|
||||
getPlans({
|
||||
productType: "완제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
itemCode: searchItemCode || undefined,
|
||||
}),
|
||||
getPlans({
|
||||
productType: "반제품",
|
||||
status: searchStatus !== "all" ? searchStatus : undefined,
|
||||
startDate: searchStartDate || undefined,
|
||||
endDate: searchEndDate || undefined,
|
||||
startDate: effectiveStart,
|
||||
endDate: effectiveEnd,
|
||||
}),
|
||||
]);
|
||||
if (finRes.success) setFinishedPlans(finRes.data || []);
|
||||
@@ -329,11 +342,10 @@ export default function ProductionPlanManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchFilters.length > 0) {
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]);
|
||||
// 검색 state 변경 시 자동 재조회 (필터 비어있어도 default 기간으로 재조회)
|
||||
fetchOrderSummary();
|
||||
fetchPlans();
|
||||
}, [searchItemCode, searchStatus, searchStartDate, searchEndDate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ========== 토글/선택 핸들러 ==========
|
||||
|
||||
@@ -1516,6 +1528,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1585,6 +1598,7 @@ export default function ProductionPlanManagementPage() {
|
||||
onEventClick={openScheduleDetail}
|
||||
onEventMove={handleEventMove}
|
||||
onEventResize={handleEventResize}
|
||||
onRangeChange={(s, e) => { setSearchStartDate(s); setSearchEndDate(e); }}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formExecutionType, setFormExecutionType] = useState<string>("serial"); // serial=순차 / parallel=병렬
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
@@ -282,6 +283,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormExecutionType("serial");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
@@ -309,6 +311,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormExecutionType(row.execution_type === "parallel" ? "parallel" : "serial");
|
||||
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
|
||||
let loadedIds: string[] = [];
|
||||
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
|
||||
@@ -362,6 +365,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -381,6 +385,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsourcePrimaryCode,
|
||||
outsource_supplier_ids: outsourceIds,
|
||||
execution_type: formExecutionType,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -418,6 +423,7 @@ export function ItemRoutingTab() {
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_ids: d.outsource_supplier_ids || [],
|
||||
execution_type: d.execution_type || "serial",
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -514,6 +520,7 @@ export function ItemRoutingTab() {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
execution_display: d.execution_type === "parallel" ? "병렬" : "순차",
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
@@ -777,6 +784,7 @@ export function ItemRoutingTab() {
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "execution_display", label: "실행방식", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
@@ -913,18 +921,32 @@ export function ItemRoutingTab() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업구분</Label>
|
||||
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="내부">내부</SelectItem>
|
||||
<SelectItem value="외주">외주</SelectItem>
|
||||
<SelectItem value="선택가능">선택가능</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">실행방식</Label>
|
||||
<Select value={formExecutionType} onValueChange={setFormExecutionType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">순차 (순서대로)</SelectItem>
|
||||
<SelectItem value="parallel">병렬 (동시 진행)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
|
||||
@@ -33,6 +33,7 @@ const INSPECTION_TABLE = "inspection_standard";
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
@@ -74,7 +75,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// FK 옵션
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
|
||||
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function ItemInspectionInfoPage() {
|
||||
code: r.item_number || r.item_code || "",
|
||||
name: r.item_name || "",
|
||||
item_type: r.type || r.item_type || "",
|
||||
size: r.size || "",
|
||||
unit: r.inventory_unit || "",
|
||||
})));
|
||||
|
||||
@@ -239,7 +241,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", size: r.size || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
|
||||
setItemTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
@@ -459,11 +461,13 @@ export default function ItemInspectionInfoPage() {
|
||||
|
||||
// item_code별 그룹핑
|
||||
const groupedData = useMemo(() => {
|
||||
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
const itemSizeMap: Record<string, string> = {};
|
||||
for (const it of itemOptions) itemSizeMap[it.code] = it.size || "";
|
||||
const map: Record<string, { item_code: string; item_name: string; size: string; is_active: string; types: string[]; rows: any[] }> = {};
|
||||
for (const row of data) {
|
||||
const key = row.item_code || row.id;
|
||||
if (!map[key]) {
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
|
||||
map[key] = { item_code: row.item_code, item_name: row.item_name, size: itemSizeMap[row.item_code] || "", is_active: row.is_active || "", types: [], rows: [] };
|
||||
}
|
||||
map[key].rows.push(row);
|
||||
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
|
||||
@@ -471,7 +475,7 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
}
|
||||
return Object.values(map);
|
||||
}, [data]);
|
||||
}, [data, itemOptions]);
|
||||
|
||||
// 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
@@ -1052,12 +1056,13 @@ export default function ItemInspectionInfoPage() {
|
||||
switch (col.key) {
|
||||
case "item_code": return <TableCell key={col.key} className="text-sm font-medium text-primary">{group.item_code}</TableCell>;
|
||||
case "item_name": return <TableCell key={col.key} className="text-sm">{group.item_name}</TableCell>;
|
||||
case "size": return <TableCell key={col.key} className="text-sm">{group.size}</TableCell>;
|
||||
case "inspection_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{group.types.map((t: string) => {
|
||||
const label = inspTypeCatOptions.find((o) => o.code === t)?.label || t;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px]">{label}</Badge>;
|
||||
return <Badge key={t} variant="secondary" className="text-[10px] whitespace-nowrap">{label}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1252,17 +1257,19 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[120px]">규격</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
|
||||
) : filteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.size}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -97,6 +97,11 @@ export default function ShippingOrderPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
@@ -136,7 +141,7 @@ export default function ShippingOrderPage() {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -152,18 +157,25 @@ export default function ShippingOrderPage() {
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
if (result.success) {
|
||||
setOrders(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
// 소스 데이터 조회
|
||||
const fetchSourceData = useCallback(async (pageOverride?: number) => {
|
||||
setSourceLoading(true);
|
||||
@@ -473,7 +485,7 @@ export default function ShippingOrderPage() {
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -482,7 +494,7 @@ export default function ShippingOrderPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -548,6 +560,13 @@ export default function ShippingOrderPage() {
|
||||
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||
onRowDoubleClick={(row) => openModal(row._order)}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-shipping-order"
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function ShippingPlanPage() {
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 서버 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
@@ -81,7 +86,7 @@ export default function ShippingPlanPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: any = { page: currentPage, size: pageSize };
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
@@ -99,19 +104,24 @@ export default function ShippingPlanPage() {
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
setTotalCount(result.total ?? result.data?.length ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
}, [searchFilters, currentPage, pageSize]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
// searchFilters 변경 시 자동 조회 + 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchFilters]);
|
||||
|
||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
@@ -209,7 +219,7 @@ export default function ShippingPlanPage() {
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
@@ -224,7 +234,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
{totalCount}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
@@ -262,7 +272,14 @@ export default function ShippingPlanPage() {
|
||||
showCheckbox
|
||||
checkedIds={checkedIds.map(String)}
|
||||
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||
showPagination={false}
|
||||
showPagination
|
||||
serverPagination
|
||||
serverCurrentPage={currentPage}
|
||||
serverPageSize={pageSize}
|
||||
serverTotalCount={totalCount}
|
||||
onServerPageChange={setCurrentPage}
|
||||
onServerPageSizeChange={(s) => { setPageSize(s); setCurrentPage(1); }}
|
||||
defaultPageSize={pageSize}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface TimelineSchedulerProps {
|
||||
onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
|
||||
/** 리사이즈 완료 */
|
||||
onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
|
||||
/** 표시 기간(이전/다음/오늘 또는 줌 변경) 변경 시 호출 — 부모가 데이터 재조회 등에 사용 */
|
||||
onRangeChange?: (startDate: string, endDate: string) => void;
|
||||
/** 상태별 색상 배열 */
|
||||
statusColors?: StatusColor[];
|
||||
/** 진행률 바 표시 여부 */
|
||||
@@ -191,6 +193,7 @@ export default function TimelineScheduler({
|
||||
onEventClick,
|
||||
onEventMove,
|
||||
onEventResize,
|
||||
onRangeChange,
|
||||
statusColors = DEFAULT_STATUS_COLORS,
|
||||
showProgress = true,
|
||||
showMilestones = true,
|
||||
@@ -249,6 +252,14 @@ export default function TimelineScheduler({
|
||||
return arr;
|
||||
}, [baseDate, config.spanDays]);
|
||||
|
||||
// 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용)
|
||||
useEffect(() => {
|
||||
if (!onRangeChange) return;
|
||||
const start = toDateStr(baseDate);
|
||||
const end = toDateStr(addDays(baseDate, config.spanDays - 1));
|
||||
onRangeChange(start, end);
|
||||
}, [baseDate, config.spanDays, onRangeChange]);
|
||||
|
||||
const totalWidth = config.cellWidth * config.spanDays;
|
||||
|
||||
// 충돌 ID 집합
|
||||
|
||||
@@ -372,6 +372,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_9/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_9/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// === COMPANY_30 ===
|
||||
"/COMPANY_30/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/kpi/production/daily": dynamic(() => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_30/master-data/company/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -601,6 +603,8 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_9/design/design-request": () => import("@/app/(main)/COMPANY_9/design/design-request/page"),
|
||||
"/COMPANY_9/design/task-management": () => import("@/app/(main)/COMPANY_9/design/task-management/page"),
|
||||
// COMPANY_30
|
||||
"/COMPANY_30/kpi/production/daily": () => import("@/app/(main)/COMPANY_30/kpi/production/daily/page"),
|
||||
"/COMPANY_16/kpi/production/daily": () => import("@/app/(main)/COMPANY_16/kpi/production/daily/page"),
|
||||
"/COMPANY_30/master-data/item-info": () => import("@/app/(main)/COMPANY_30/master-data/item-info/page"),
|
||||
"/COMPANY_30/master-data/department": () => import("@/app/(main)/COMPANY_30/master-data/department/page"),
|
||||
"/COMPANY_30/master-data/company": () => import("@/app/(main)/COMPANY_30/master-data/company/page"),
|
||||
@@ -818,6 +822,7 @@ const COMPANY_PAGE_PREFIXES = [
|
||||
"/quality/",
|
||||
"/mold/",
|
||||
"/monitoring/",
|
||||
"/kpi/",
|
||||
];
|
||||
|
||||
function isCompanyPage(url: string): boolean {
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface RoutingDetail {
|
||||
outsource_supplier: string;
|
||||
outsource_supplier_ids?: string[];
|
||||
outsource_supplier_list?: string[]; // legacy code 배열 (호환용)
|
||||
/** 실행 방식 — 카테고리 코드 (item_routing_detail.execution_type 컬럼) */
|
||||
execution_type?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
|
||||
@@ -108,6 +108,8 @@ export async function getPlans(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemCode?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.productType) queryParams.set("productType", params.productType);
|
||||
@@ -115,11 +117,20 @@ export async function getPlans(params?: {
|
||||
if (params?.startDate) queryParams.set("startDate", params.startDate);
|
||||
if (params?.endDate) queryParams.set("endDate", params.endDate);
|
||||
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
|
||||
if (params?.page != null) queryParams.set("page", String(params.page));
|
||||
if (params?.size != null) queryParams.set("size", String(params.size));
|
||||
|
||||
const qs = queryParams.toString();
|
||||
const url = `/production/plans${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data as { success: boolean; data: ProductionPlan[] };
|
||||
return response.data as {
|
||||
success: boolean;
|
||||
data: ProductionPlan[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */
|
||||
@@ -145,16 +156,27 @@ export async function getOrderSummary(params?: {
|
||||
excludePlanned?: boolean;
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.excludePlanned) queryParams.set("excludePlanned", "true");
|
||||
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
|
||||
if (params?.itemName) queryParams.set("itemName", params.itemName);
|
||||
if (params?.page != null) queryParams.set("page", String(params.page));
|
||||
if (params?.size != null) queryParams.set("size", String(params.size));
|
||||
|
||||
const qs = queryParams.toString();
|
||||
const url = `/production/order-summary${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data as { success: boolean; data: OrderSummaryItem[] };
|
||||
return response.data as {
|
||||
success: boolean;
|
||||
data: OrderSummaryItem[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 안전재고 부족분 조회 */
|
||||
|
||||
@@ -90,11 +90,20 @@ export interface ShipmentPlanListParams {
|
||||
status?: string;
|
||||
customer?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function getShipmentPlanList(params: ShipmentPlanListParams) {
|
||||
const res = await apiClient.get("/shipping-plan/list", { params });
|
||||
return res.data as { success: boolean; data: ShipmentPlanListItem[] };
|
||||
return res.data as {
|
||||
success: boolean;
|
||||
data: ShipmentPlanListItem[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 출하계획 단건 수정
|
||||
@@ -114,9 +123,18 @@ export async function getShippingOrderList(params?: {
|
||||
status?: string;
|
||||
customer?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const res = await apiClient.get("/shipping-order/list", { params });
|
||||
return res.data as { success: boolean; data: any[] };
|
||||
return res.data as {
|
||||
success: boolean;
|
||||
data: any[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveShippingOrder(data: any) {
|
||||
|
||||
+21
-2
@@ -135,7 +135,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
[config.dataSource]
|
||||
);
|
||||
|
||||
// 작업 항목 조회
|
||||
// 작업 항목 조회 + 각 phase별 첫 항목 자동 선택 (상세 영역이 비어 보이는 오해 방지)
|
||||
const fetchWorkItems = useCallback(async (routingDetailId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -143,7 +143,26 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
`${API_BASE}/routing-detail/${routingDetailId}/work-items`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setWorkItems(res.data.items || []);
|
||||
const items: WorkItem[] = res.data.items || [];
|
||||
setWorkItems(items);
|
||||
|
||||
// 각 phase별 첫 번째 항목 자동 선택 + 상세 병렬 로드
|
||||
const firstByPhase: Record<string, WorkItem> = {};
|
||||
for (const it of items) {
|
||||
const phase = (it as any).work_phase;
|
||||
if (phase && !firstByPhase[phase]) firstByPhase[phase] = it;
|
||||
}
|
||||
await Promise.all(
|
||||
Object.entries(firstByPhase).map(async ([phaseKey, item]) => {
|
||||
try {
|
||||
const dr = await apiClient.get(`${API_BASE}/work-items/${item.id}/details`);
|
||||
if (dr.data?.success) {
|
||||
setSelectedDetailsByPhase((prev) => ({ ...prev, [phaseKey]: dr.data.data }));
|
||||
setSelectedWorkItemIdByPhase((prev) => ({ ...prev, [phaseKey]: item.id }));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("작업 항목 조회 실패", err);
|
||||
|
||||
Reference in New Issue
Block a user