feat: enhance application with receiving management and global font size adjustments

- Added new route for receiving management, allowing users to manage incoming goods effectively.
- Updated global CSS to unify font sizes across the application, ensuring a consistent user experience.
- Modified customer and sales order fetching logic to improve data retrieval and handling, including enhanced error logging for better debugging.

These changes aim to streamline the receiving process and improve overall UI consistency within the application.
This commit is contained in:
kjs 2026-03-20 16:09:39 +09:00
parent a5eba3a4ca
commit 8c29225043
8 changed files with 2057 additions and 56 deletions

View File

@ -150,6 +150,7 @@ import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -351,6 +352,7 @@ app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -0,0 +1,487 @@
/**
*
*
* :
* - purchase_order_mng ()
* - shipment_instruction + shipment_instruction_detail ()
* - item_info ()
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 입고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
inbound_type,
inbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`im.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (inbound_type && inbound_type !== "all") {
conditions.push(`im.inbound_type = $${paramIdx}`);
params.push(inbound_type);
paramIdx++;
}
if (inbound_status && inbound_status !== "all") {
conditions.push(`im.inbound_status = $${paramIdx}`);
params.push(inbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
im.*,
wh.warehouse_name
FROM inbound_mng im
LEFT JOIN warehouse_info wh
ON im.warehouse_code = wh.warehouse_code
AND im.company_code = wh.company_code
${whereClause}
ORDER BY im.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("입고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("입고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO inbound_mng (
company_code, inbound_number, inbound_type, inbound_date,
reference_number, supplier_code, supplier_name,
item_number, item_name, spec, material, unit,
inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager, memo,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4::date,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22, $23,
$24, $25,
NOW(), $26, $26, '입고'
) RETURNING *`,
[
companyCode,
inbound_number || item.inbound_number,
item.inbound_type,
inbound_date || item.inbound_date,
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
item.item_number || null,
item.item_name || null,
item.spec || null,
item.material || null,
item.unit || "EA",
item.inbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.inbound_status || "대기",
item.inspection_status || "대기",
inspector || item.inspector || null,
manager || item.manager || null,
memo || item.memo || null,
item.source_table || null,
item.source_id || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
remain_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("입고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
inbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 입고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 입고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
inbound_qty = COALESCE($2, inbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
inbound_status = COALESCE($8, inbound_status),
inspection_status = COALESCE($9, inspection_status),
inspector = COALESCE($10, inspector),
manager = COALESCE($11, manager),
memo = COALESCE($12, memo),
updated_date = NOW(),
updated_by = $13
WHERE id = $14 AND company_code = $15
RETURNING *`,
[
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
logger.info("입고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("입고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고 삭제
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("입고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("입고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 구매입고용: 발주 데이터 조회 (미입고분)
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 잔량이 있는 것만 조회
conditions.push(
`COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0`
);
conditions.push(`status NOT IN ('입고완료', '취소')`);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
) AS remain_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품입고용: 출하 데이터 조회
export async function getShipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타입고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `RCV-${yyyy}-`;
const result = await pool.query(
`SELECT inbound_number FROM inbound_mng
WHERE company_code = $1 AND inbound_number LIKE $2
ORDER BY inbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].inbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("입고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -0,0 +1,40 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as receivingController from "../controllers/receivingController";
const router = Router();
router.use(authenticateToken);
// 입고 목록 조회
router.get("/list", receivingController.getList);
// 입고번호 자동생성
router.get("/generate-number", receivingController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", receivingController.getWarehouses);
// 소스 데이터: 발주 (구매입고)
router.get("/source/purchase-orders", receivingController.getPurchaseOrders);
// 소스 데이터: 출하 (반품입고)
router.get("/source/shipments", receivingController.getShipments);
// 소스 데이터: 품목 (기타입고)
router.get("/source/items", receivingController.getItems);
// 입고 등록
router.post("/", receivingController.create);
// 입고 수정
router.put("/:id", receivingController.update);
// 입고 삭제
router.delete("/:id", receivingController.deleteReceiving);
export default router;

File diff suppressed because it is too large Load Diff

View File

@ -212,21 +212,24 @@ export default function ClaimManagementPage() {
}, []); }, []);
// 거래처 목록 조회 // 거래처 목록 조회
const fetchCustomers = useCallback(async () => { const fetchCustomers = useCallback(async (force = false) => {
if (customers.length > 0) return; if (!force && customers.length > 0) return;
setCustomersLoading(true); setCustomersLoading(true);
try { try {
const res = await apiClient.post("/table-management/tables/customer_mng/data", { const res = await apiClient.post("/table-management/tables/customer_mng/data", {
page: 1, page: 1,
size: 9999, size: 9999,
autoFilter: true, autoFilter: { enabled: true },
}); });
if (res.data?.success && res.data?.data?.rows) { const rows = res.data?.data?.data || res.data?.data?.rows;
const list: CustomerOption[] = res.data.data.rows.map((row: any) => ({ if (res.data?.success && Array.isArray(rows)) {
customerCode: row.customer_code || "", const list: CustomerOption[] = rows.map((row: any) => ({
customerCode: row.customer_code || row.id || "",
customerName: row.customer_name || "", customerName: row.customer_name || "",
})); }));
setCustomers(list); setCustomers(list);
} else {
console.warn("거래처 응답 구조 확인:", JSON.stringify(res.data, null, 2));
} }
} catch (e) { } catch (e) {
console.error("거래처 목록 조회 실패:", e); console.error("거래처 목록 조회 실패:", e);
@ -236,19 +239,20 @@ export default function ClaimManagementPage() {
}, [customers.length]); }, [customers.length]);
// 수주 목록 조회 // 수주 목록 조회
const fetchSalesOrders = useCallback(async () => { const fetchSalesOrders = useCallback(async (force = false) => {
if (salesOrders.length > 0) return; if (!force && salesOrders.length > 0) return;
setOrdersLoading(true); setOrdersLoading(true);
try { try {
const res = await apiClient.post("/table-management/tables/sales_order_mng/data", { const res = await apiClient.post("/table-management/tables/sales_order_mng/data", {
page: 1, page: 1,
size: 9999, size: 9999,
autoFilter: true, autoFilter: { enabled: true },
}); });
if (res.data?.success && res.data?.data?.rows) { const rows = res.data?.data?.data || res.data?.data?.rows;
if (res.data?.success && Array.isArray(rows)) {
const seen = new Set<string>(); const seen = new Set<string>();
const list: SalesOrderOption[] = []; const list: SalesOrderOption[] = [];
for (const row of res.data.data.rows) { for (const row of rows) {
const orderNo = row.order_no || ""; const orderNo = row.order_no || "";
if (!orderNo || seen.has(orderNo)) continue; if (!orderNo || seen.has(orderNo)) continue;
seen.add(orderNo); seen.add(orderNo);
@ -259,6 +263,8 @@ export default function ClaimManagementPage() {
}); });
} }
setSalesOrders(list); setSalesOrders(list);
} else {
console.warn("수주 응답 구조 확인:", JSON.stringify(res.data, null, 2));
} }
} catch (e) { } catch (e) {
console.error("수주 목록 조회 실패:", e); console.error("수주 목록 조회 실패:", e);
@ -343,6 +349,8 @@ export default function ClaimManagementPage() {
processContent: "", processContent: "",
}); });
setIsModalOpen(true); setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
}; };
const openEditModal = (claimNo: string) => { const openEditModal = (claimNo: string) => {
@ -351,6 +359,8 @@ export default function ClaimManagementPage() {
setIsEditMode(true); setIsEditMode(true);
setFormData({ ...claim }); setFormData({ ...claim });
setIsModalOpen(true); setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
}; };
const handleFormChange = (field: keyof Claim, value: string) => { const handleFormChange = (field: keyof Claim, value: string) => {
@ -893,7 +903,7 @@ export default function ClaimManagementPage() {
role="combobox" role="combobox"
aria-expanded={customerOpen} aria-expanded={customerOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal" className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal"
onClick={() => fetchCustomers()} onClick={() => fetchCustomers(false)}
> >
{formData.customerName || "거래처 선택"} {formData.customerName || "거래처 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -985,7 +995,7 @@ export default function ClaimManagementPage() {
role="combobox" role="combobox"
aria-expanded={orderOpen} aria-expanded={orderOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal" className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal"
onClick={() => fetchSalesOrders()} onClick={() => fetchSalesOrders(false)}
> >
{formData.orderNo || "수주번호 선택"} {formData.orderNo || "수주번호 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />

View File

@ -124,6 +124,23 @@ export default function ShippingPlanPage() {
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
const groupedData = useMemo(() => {
const orderMap = new Map<string, ShipmentPlanListItem[]>();
const orderKeys: string[] = [];
data.forEach(plan => {
const key = plan.order_no || `_no_order_${plan.id}`;
if (!orderMap.has(key)) {
orderMap.set(key, []);
orderKeys.push(key);
}
orderMap.get(key)!.push(plan);
});
return orderKeys.map(key => ({
orderNo: key,
plans: orderMap.get(key)!,
}));
}, [data]);
const handleRowClick = (plan: ShipmentPlanListItem) => { const handleRowClick = (plan: ShipmentPlanListItem) => {
if (isDetailChanged && selectedId !== plan.id) { if (isDetailChanged && selectedId !== plan.id) {
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return; if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
@ -298,59 +315,73 @@ export default function ShippingPlanPage() {
onCheckedChange={handleCheckAll} onCheckedChange={handleCheckAll}
/> />
</TableHead> </TableHead>
<TableHead className="w-[50px] text-center">No</TableHead> <TableHead className="w-[160px]"></TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[120px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[80px] text-right"></TableHead> <TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead> <TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead> <TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead> <TableHead className="w-[80px] text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.length === 0 ? ( {groupedData.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={11} className="h-32 text-center text-muted-foreground"> <TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.map((plan, idx) => ( groupedData.map((group) =>
<TableRow group.plans.map((plan, planIdx) => (
key={plan.id} <TableRow
className={cn( key={plan.id}
"cursor-pointer hover:bg-muted/50 transition-colors", className={cn(
selectedId === plan.id && "bg-primary/5", "cursor-pointer hover:bg-muted/50 transition-colors",
plan.status === "CANCELLED" && "opacity-60 bg-slate-50" selectedId === plan.id && "bg-primary/5",
)} plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
onClick={() => handleRowClick(plan)} planIdx === 0 && "border-t-2 border-t-border"
> )}
<TableCell className="text-center" onClick={e => e.stopPropagation()}> onClick={() => handleRowClick(plan)}
<Checkbox >
checked={checkedIds.includes(plan.id)} <TableCell className="text-center" onClick={e => e.stopPropagation()}>
onCheckedChange={(c) => handleCheck(plan.id, c as boolean)} {planIdx === 0 && (
disabled={plan.status === "CANCELLED"} <Checkbox
/> checked={group.plans.every(p => checkedIds.includes(p.id))}
</TableCell> onCheckedChange={(c) => {
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell> if (c) {
<TableCell> setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}> } else {
{getStatusLabel(plan.status)} setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
</span> }
</TableCell> }}
<TableCell className="font-medium">{plan.order_no || "-"}</TableCell> />
<TableCell>{plan.customer_name || "-"}</TableCell> )}
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell> </TableCell>
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell> <TableCell className="font-medium">
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell> {planIdx === 0 ? (plan.order_no || "-") : ""}
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell> </TableCell>
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell> <TableCell className="text-center">
<TableCell className="text-center">{formatDate(plan.due_date)}</TableCell> {planIdx === 0 ? formatDate(plan.due_date) : ""}
</TableRow> </TableCell>
)) <TableCell>
{planIdx === 0 ? (plan.customer_name || "-") : ""}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell>
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>
</TableRow>
))
)
)} )}
</TableBody> </TableBody>
</Table> </Table>
@ -390,10 +421,6 @@ export default function ShippingPlanPage() {
<section> <section>
<h3 className="text-sm font-semibold mb-3"> </h3> <h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm"> <div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className="font-medium">{selectedPlan.shipment_plan_no || "-"}</span>
</div>
<div> <div>
<span className="text-muted-foreground text-xs block mb-1"></span> <span className="text-muted-foreground text-xs block mb-1"></span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}> <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>

View File

@ -508,6 +508,16 @@ select {
font-family: "Gaegu", cursive; font-family: "Gaegu", cursive;
} }
/* ===== 전체 폰트 사이즈 16px 통일 (버튼 제외) ===== */
body *:not(button, [role="button"]) {
font-size: 16px !important;
}
body button *,
body [role="button"] * {
font-size: inherit !important;
}
/* ===== Component-Specific Overrides ===== */ /* ===== Component-Specific Overrides ===== */
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */ /* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
/* 예: Calendar, Table 등의 미세 조정 */ /* 예: Calendar, Table 등의 미세 조정 */

View File

@ -0,0 +1,179 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface InboundItem {
id: string;
company_code: string;
inbound_number: string;
inbound_type: string;
inbound_date: string;
reference_number: string | null;
supplier_code: string | null;
supplier_name: string | null;
item_number: string | null;
item_name: string | null;
spec: string | null;
material: string | null;
unit: string | null;
inbound_qty: number;
unit_price: number;
total_amount: number;
lot_number: string | null;
warehouse_code: string | null;
warehouse_name?: string | null;
location_code: string | null;
inbound_status: string;
inspection_status: string | null;
inspector: string | null;
manager: string | null;
memo: string | null;
source_table: string | null;
source_id: string | null;
created_date: string;
created_by: string | null;
}
export interface PurchaseOrderSource {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
order_qty: number;
received_qty: number;
remain_qty: number;
unit_price: number;
status: string;
due_date: string | null;
}
export interface ShipmentSource {
detail_id: number;
instruction_id: number;
instruction_no: string;
instruction_date: string;
partner_id: string;
instruction_status: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
ship_qty: number;
order_qty: number;
source_type: string | null;
}
export interface ItemSource {
id: string;
item_number: string;
item_name: string;
spec: string | null;
material: string | null;
unit: string | null;
standard_price: number;
}
export interface WarehouseOption {
warehouse_code: string;
warehouse_name: string;
warehouse_type: string;
}
export interface CreateReceivingPayload {
inbound_number: string;
inbound_date: string;
warehouse_code?: string;
location_code?: string;
inspector?: string;
manager?: string;
memo?: string;
items: Array<{
inbound_type: string;
reference_number?: string;
supplier_code?: string;
supplier_name?: string;
item_number?: string;
item_name?: string;
spec?: string;
material?: string;
unit?: string;
inbound_qty: number;
unit_price?: number;
total_amount?: number;
lot_number?: string;
warehouse_code?: string;
location_code?: string;
inbound_status?: string;
inspection_status?: string;
inspector?: string;
manager?: string;
memo?: string;
source_table: string;
source_id: string;
}>;
}
// --- API 호출 ---
export async function getReceivingList(params?: {
inbound_type?: string;
inbound_status?: string;
search_keyword?: string;
date_from?: string;
date_to?: string;
}) {
const res = await apiClient.get("/receiving/list", { params });
return res.data as { success: boolean; data: InboundItem[] };
}
export async function createReceiving(payload: CreateReceivingPayload) {
const res = await apiClient.post("/receiving", payload);
return res.data as { success: boolean; data: InboundItem[]; message?: string };
}
export async function updateReceiving(id: string, payload: Partial<InboundItem>) {
const res = await apiClient.put(`/receiving/${id}`, payload);
return res.data as { success: boolean; data: InboundItem };
}
export async function deleteReceiving(id: string) {
const res = await apiClient.delete(`/receiving/${id}`);
return res.data as { success: boolean; message?: string };
}
export async function generateReceivingNumber() {
const res = await apiClient.get("/receiving/generate-number");
return res.data as { success: boolean; data: string };
}
export async function getReceivingWarehouses() {
const res = await apiClient.get("/receiving/warehouses");
return res.data as { success: boolean; data: WarehouseOption[] };
}
// 소스 데이터 조회
export async function getPurchaseOrderSources(keyword?: string) {
const res = await apiClient.get("/receiving/source/purchase-orders", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: PurchaseOrderSource[] };
}
export async function getShipmentSources(keyword?: string) {
const res = await apiClient.get("/receiving/source/shipments", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ShipmentSource[] };
}
export async function getItemSources(keyword?: string) {
const res = await apiClient.get("/receiving/source/items", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemSource[] };
}