Enhance backend controllers, frontend pages, and V2 components

- Fix department, receiving, shippingOrder, shippingPlan controllers
- Update admin pages (company management, disk usage)
- Improve sales/logistics pages (order, shipping, outbound, receiving)
- Enhance V2 components (file-upload, split-panel-layout, table-list)
- Add SmartSelect common component
- Update DataGrid, FullscreenDialog common components
- Add gitignore rules for personal pipeline tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kmh 2026-03-30 11:52:03 +09:00
parent 348da95823
commit b97ca1a1c5
23 changed files with 1012 additions and 365 deletions

4
.gitignore vendored
View File

@ -206,6 +206,10 @@ mcp-task-queue/
.cursor/rules/multi-agent-reviewer.mdc
.cursor/rules/multi-agent-knowledge.mdc
# MCP Agent Orchestrator (개인 파이프라인 도구)
mcp-agent-orchestrator/
.mcp.json
# 파이프라인 회고록 (자동 생성)
docs/retrospectives/
mes-architecture-guide.md

View File

@ -67,16 +67,17 @@ export async function getDepartments(req: AuthenticatedRequest, res: Response):
export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const companyCode = req.user!.companyCode;
const department = await queryOne<any>(`
SELECT
SELECT
dept_code,
dept_name,
company_code,
parent_dept_code
FROM dept_info
WHERE dept_code = $1
`, [deptCode]);
WHERE dept_code = $1 AND company_code = $2
`, [deptCode, companyCode]);
if (!department) {
res.status(404).json({
@ -105,7 +106,7 @@ export async function getDepartment(req: AuthenticatedRequest, res: Response): P
export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { companyCode } = req.params;
const { dept_name, parent_dept_code } = req.body;
const { dept_name, parent_dept_code, dept_code: requestedDeptCode } = req.body;
if (!dept_name || !dept_name.trim()) {
res.status(400).json({
@ -131,6 +132,30 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response)
return;
}
// 프론트에서 채번 시스템으로 할당된 dept_code 필수
if (!requestedDeptCode || !requestedDeptCode.trim()) {
res.status(400).json({
success: false,
message: "부서코드가 필요합니다. 채번 규칙을 먼저 등록해주세요.",
});
return;
}
// 같은 회사 내 부서코드 중복 체크
const codeDuplicate = await queryOne<any>(`
SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2
`, [requestedDeptCode.trim(), companyCode]);
if (codeDuplicate) {
res.status(409).json({
success: false,
message: `부서코드 "${requestedDeptCode}" 가 이미 존재합니다.`,
});
return;
}
const deptCode = requestedDeptCode.trim();
// 회사 이름 조회
const company = await queryOne<any>(`
SELECT company_name FROM company_mng WHERE company_code = $1
@ -138,16 +163,6 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response)
const companyName = company?.company_name || companyCode;
// 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...)
const codeResult = await queryOne<any>(`
SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number
FROM dept_info
WHERE dept_code ~ '^DEPT_[0-9]+$'
`);
const nextNumber = codeResult?.next_number || 1;
const deptCode = `DEPT_${nextNumber}`;
// 부서 생성
const result = await query<any>(`
INSERT INTO dept_info (
@ -207,6 +222,7 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
try {
const { deptCode } = req.params;
const { dept_name, parent_dept_code } = req.body;
const companyCode = req.user!.companyCode;
if (!dept_name || !dept_name.trim()) {
res.status(400).json({
@ -218,12 +234,12 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
const result = await query<any>(`
UPDATE dept_info
SET
SET
dept_name = $1,
parent_dept_code = $2
WHERE dept_code = $3
WHERE dept_code = $3 AND company_code = $4
RETURNING *
`, [dept_name.trim(), parent_dept_code || null, deptCode]);
`, [dept_name.trim(), parent_dept_code || null, deptCode, companyCode]);
if (result.length === 0) {
res.status(404).json({
@ -270,13 +286,14 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const companyCode = req.user!.companyCode;
// 하위 부서 확인
const hasChildren = await queryOne<any>(`
SELECT COUNT(*) as count
FROM dept_info
WHERE parent_dept_code = $1
`, [deptCode]);
WHERE parent_dept_code = $1 AND company_code = $2
`, [deptCode, companyCode]);
if (parseInt(hasChildren?.count || "0") > 0) {
res.status(400).json({
@ -286,21 +303,22 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
return;
}
// 부서원 삭제 (부서 삭제 전에 먼저 삭제)
// 부서원 삭제 (부서 삭제 전에 먼저 삭제 — 해당 회사 부서만)
const deletedMembers = await query<any>(`
DELETE FROM user_dept
WHERE dept_code = $1
AND dept_code IN (SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2)
RETURNING user_id
`, [deptCode]);
`, [deptCode, companyCode]);
const memberCount = deletedMembers.length;
// 부서 삭제
const result = await query<any>(`
DELETE FROM dept_info
WHERE dept_code = $1
WHERE dept_code = $1 AND company_code = $2
RETURNING dept_code, dept_name
`, [deptCode]);
`, [deptCode, companyCode]);
if (result.length === 0) {
res.status(404).json({
@ -352,9 +370,10 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode } = req.params;
const companyCode = req.user!.companyCode;
const members = await query<any>(`
SELECT
SELECT
u.user_id,
u.user_name,
u.email,
@ -367,9 +386,9 @@ export async function getDepartmentMembers(req: AuthenticatedRequest, res: Respo
FROM user_dept ud
JOIN user_info u ON ud.user_id = u.user_id
JOIN dept_info d ON ud.dept_code = d.dept_code
WHERE ud.dept_code = $1
WHERE ud.dept_code = $1 AND d.company_code = $2
ORDER BY ud.is_primary DESC, u.user_name
`, [deptCode]);
`, [deptCode, companyCode]);
res.status(200).json({
success: true,
@ -438,6 +457,7 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon
try {
const { deptCode } = req.params;
const { user_id } = req.body;
const companyCode = req.user!.companyCode;
if (!user_id) {
res.status(400).json({
@ -447,12 +467,25 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon
return;
}
// 부서 소유권 확인 (해당 회사의 부서인지)
const dept = await queryOne<any>(`
SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2
`, [deptCode, companyCode]);
if (!dept) {
res.status(403).json({
success: false,
message: "해당 부서에 접근할 권한이 없습니다.",
});
return;
}
// 사용자 존재 확인
const user = await queryOne<any>(`
SELECT user_id, user_name
FROM user_info
WHERE user_id = $1
`, [user_id]);
WHERE user_id = $1 AND company_code = $2
`, [user_id, companyCode]);
if (!user) {
res.status(404).json({
@ -512,6 +545,20 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon
export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode, userId } = req.params;
const companyCode = req.user!.companyCode;
// 부서 소유권 확인
const dept = await queryOne<any>(`
SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2
`, [deptCode, companyCode]);
if (!dept) {
res.status(403).json({
success: false,
message: "해당 부서에 접근할 권한이 없습니다.",
});
return;
}
const result = await query<any>(`
DELETE FROM user_dept
@ -548,6 +595,20 @@ export async function removeDepartmentMember(req: AuthenticatedRequest, res: Res
export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { deptCode, userId } = req.params;
const companyCode = req.user!.companyCode;
// 부서 소유권 확인
const dept = await queryOne<any>(`
SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2
`, [deptCode, companyCode]);
if (!dept) {
res.status(403).json({
success: false,
message: "해당 부서에 접근할 권한이 없습니다.",
});
return;
}
// 다른 부서의 주 부서 해제
await query<any>(`

View File

@ -2,7 +2,7 @@
*
*
* :
* - purchase_order_mng ()
* - purchase_order_mng ( ) + purchase_detail ( )
* - shipment_instruction + shipment_instruction_detail ()
* - item_info ()
*/
@ -228,6 +228,39 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[item.inbound_qty || 0, item.source_id, companyCode]
);
}
// 구매입고인 경우 purchase_detail 기반 발주의 헤더 상태 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
// 해당 디테일의 발주번호 조회
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 해당 발주의 모든 디테일 잔량 확인
const unreceived = await client.query(
`SELECT pd.id
FROM purchase_detail pd
LEFT JOIN (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
GROUP BY source_id
) r ON r.source_id = pd.id
WHERE pd.purchase_no = $2 AND pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0
LIMIT 1`,
[companyCode, purchaseNo]
);
const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고';
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
);
}
}
}
await client.query("COMMIT");
@ -332,50 +365,115 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
}
}
// 구매입고용: 발주 데이터 조회 (미입고분)
// 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const { keyword, page, pageSize } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
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 ('입고완료', '취소')`);
let keywordConditionDetail = "";
let keywordConditionLegacy = "";
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`;
keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`;
params.push(`%${keyword}%`);
paramIdx++;
}
const baseQuery = `
WITH detail_received AS (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
GROUP BY source_id
),
combined AS (
-- ( - , )
SELECT
pd.id,
COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no,
po.order_date,
COALESCE(pd.supplier_code, po.supplier_code) AS supplier_code,
COALESCE(pd.supplier_name, po.supplier_name) AS supplier_name,
COALESCE(NULLIF(pd.item_code, ''), ii.item_number) AS item_code,
COALESCE(NULLIF(pd.item_name, ''), ii.item_name) AS item_name,
COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec,
COALESCE(NULLIF(pd.material, ''), ii.material) AS material,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(dr.total_received, 0) AS received_qty,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) AS remain_qty,
COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price,
COALESCE(po.status, '') AS status,
COALESCE(pd.due_date, po.due_date) AS due_date,
'purchase_detail' AS source_table
FROM purchase_detail pd
LEFT JOIN purchase_order_mng po
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
LEFT JOIN item_info ii ON pd.item_id = ii.id
LEFT JOIN detail_received dr ON dr.source_id = pd.id
WHERE pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) > 0
AND COALESCE(pd.approval_status, '') NOT IN ('반려')
AND COALESCE(po.status, '') NOT IN ('입고완료', '취소')
${keywordConditionDetail}
UNION ALL
-- (purchase_detail에 )
SELECT
po.id,
po.purchase_no,
po.order_date,
po.supplier_code,
po.supplier_name,
po.item_code,
po.item_name,
po.spec,
po.material,
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0)
) AS remain_qty,
COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price,
po.status,
po.due_date,
'purchase_order_mng' AS source_table
FROM purchase_order_mng po
WHERE po.company_code = $1
AND NOT EXISTS (
SELECT 1 FROM purchase_detail pd
WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
)
AND COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0)
) > 0
AND po.status NOT IN ('입고완료', '취소')
${keywordConditionLegacy}
)`;
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`,
const countResult = await pool.query(
`${baseQuery} SELECT COUNT(*) AS total FROM combined`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: result.rows });
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
@ -386,7 +484,10 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
export async function getShipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const { keyword, page, pageSize } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
@ -400,8 +501,20 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const result = await pool.query(
const countResult = await pool.query(
`SELECT COUNT(*) AS total
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
WHERE ${whereClause}`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
@ -420,12 +533,13 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
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`,
WHERE ${whereClause}
ORDER BY si.instruction_date DESC, si.instruction_no
LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: result.rows });
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("출하 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
@ -436,7 +550,10 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const { keyword, page, pageSize } = req.query;
const currentPage = Math.max(1, Number(page) || 1);
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
const offset = (currentPage - 1) * limit;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
@ -450,18 +567,27 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const result = await pool.query(
const countResult = await pool.query(
`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`,
params
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = 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`,
WHERE ${whereClause}
ORDER BY item_name
LIMIT ${limit} OFFSET ${offset}`,
params
);
return res.json({ success: true, data: result.rows });
return res.json({ success: true, data: dataResult.rows, totalCount });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });

View File

@ -338,7 +338,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code
WHERE ${whereClause}
`;
@ -354,7 +354,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(c.customer_name, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
sp.detail_id, sp.sales_order_id
${fromClause}

View File

@ -215,7 +215,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(c.customer_name, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
COALESCE(d.due_date, m.due_date::text, '') AS due_date,
COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty,
@ -232,7 +232,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code
ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code
AND sp.company_code = c.company_code
${whereClause}
ORDER BY sp.created_date DESC

View File

@ -52,8 +52,8 @@ export default function CompanyPage() {
} = useCompanyManagement();
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
<div className="flex h-full flex-col overflow-auto bg-background">
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>

View File

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

View File

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

View File

@ -25,6 +25,8 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
@ -78,6 +80,10 @@ export default function DepartmentPage() {
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
@ -112,7 +118,6 @@ export default function DepartmentPage() {
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
@ -144,10 +149,28 @@ export default function DepartmentPage() {
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openDeptEdit = () => {
@ -159,20 +182,40 @@ export default function DepartmentPage() {
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
const companyCode = user?.companyCode || "";
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) {
toast.error((response as any).error || "등록에 실패했습니다.");
return;
}
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
@ -193,10 +236,9 @@ export default function DepartmentPage() {
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
toast.success(response.message || "삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
@ -373,8 +415,9 @@ export default function DepartmentPage() {
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
<Input value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
className="h-9" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>

View File

@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { SmartSelect } from "@/components/common/SmartSelect";
const DETAIL_TABLE = "sales_order_detail";
@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng";
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "part_name", label: "품명", width: "w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[70px]" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
@ -85,7 +88,12 @@ export default function SalesOrderPage() {
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20);
const [itemTotalPages, setItemTotalPages] = useState(0);
const [itemTotal, setItemTotal] = useState(0);
const [itemPageInput, setItemPageInput] = useState("1");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
@ -221,6 +229,23 @@ export default function SalesOrderPage() {
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강)
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
let masterMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
for (const m of masters) {
masterMap[m.order_no] = m;
}
} catch { /* skip */ }
}
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@ -247,19 +272,20 @@ export default function SalesOrderPage() {
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
@ -330,7 +356,6 @@ export default function SalesOrderPage() {
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
@ -377,7 +402,6 @@ export default function SalesOrderPage() {
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
@ -433,7 +457,6 @@ export default function SalesOrderPage() {
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
@ -441,7 +464,9 @@ export default function SalesOrderPage() {
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
setItemSearchLoading(true);
try {
const filters: any[] = [];
@ -449,18 +474,45 @@ export default function SalesOrderPage() {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page: p, size: s,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
const resData = res.data?.data;
setItemSearchResults(resData?.data || resData?.rows || []);
setItemTotal(resData?.total || 0);
setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const handleItemPageChange = (newPage: number) => {
if (newPage < 1 || newPage > itemTotalPages) return;
setItemPage(newPage);
setItemPageInput(String(newPage));
searchItems(newPage);
};
const commitItemPageInput = () => {
const parsed = parseInt(itemPageInput, 10);
if (isNaN(parsed) || itemPageInput.trim() === "") {
setItemPageInput(String(itemPage));
return;
}
const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1));
if (clamped !== itemPage) handleItemPageChange(clamped);
setItemPageInput(String(clamped));
};
const triggerNewSearch = () => {
setItemPage(1);
setItemPageInput("1");
searchItems(1);
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
@ -492,7 +544,7 @@ export default function SalesOrderPage() {
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
// 단가 조회 실패 시 무시
}
}
@ -516,15 +568,17 @@ export default function SalesOrderPage() {
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
standard_price: item.standard_price || "",
unit_price: unitPrice,
amount: "",
currency_code: item.currency_code || "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectedMap(new Map());
setItemSelectOpen(false);
};
@ -655,30 +709,15 @@ export default function SalesOrderPage() {
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["sell_mode"] || []} value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["input_mode"] || []} value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["price_mode"] || []} value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
</div>
</div>
@ -687,39 +726,23 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["partner_id"] || []} value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["manager_id"] || []} value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
<SmartSelect options={deliveryOptions} value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
}} placeholder="납품처 선택" />
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
@ -738,21 +761,11 @@ export default function SalesOrderPage() {
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["incoterms"] || []} value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<SmartSelect options={categoryOptions["payment_term"] || []} value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -781,28 +794,30 @@ export default function SalesOrderPage() {
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Button size="sm" variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemPage(1); setItemPageInput("1"); setItemSearchKeyword(""); setItemSelectOpen(true); searchItems(1); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<Table className="table-fixed">
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
<TableRow><TableCell colSpan={11} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
@ -814,6 +829,7 @@ export default function SalesOrderPage() {
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell className="text-sm text-right text-muted-foreground">{row.standard_price ? Number(row.standard_price).toLocaleString() : ""}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
@ -823,6 +839,10 @@ export default function SalesOrderPage() {
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<Input value={row.currency_code || ""} onChange={(e) => updateDetailRow(idx, "currency_code", e.target.value)}
className="h-8 text-sm" />
</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
@ -851,22 +871,26 @@ export default function SalesOrderPage() {
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<div className="overflow-auto max-h-[320px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
setItemSelectedMap((prev) => {
const next = new Map(prev);
if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i));
else itemSearchResults.forEach((i) => next.delete(i.id));
return next;
});
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
@ -880,14 +904,14 @@ export default function SalesOrderPage() {
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
<TableRow key={item.id} className={cn("cursor-pointer", itemSelectedMap.has(item.id) && "bg-primary/5")}
onClick={() => setItemSelectedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
<input type="checkbox" checked={itemSelectedMap.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
@ -899,13 +923,53 @@ export default function SalesOrderPage() {
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between w-full border-t pt-2">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">:</span>
<input type="number" min={1} max={200} value={itemPageSize}
onChange={(e) => {
const v = Math.min(200, Math.max(1, Number(e.target.value) || 20));
setItemPageSize(v);
setItemPage(1);
setItemPageInput("1");
searchItems(1, v);
}}
className="h-7 w-14 rounded-md border px-1 text-center text-xs" />
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(1)} disabled={itemPage === 1 || itemSearchLoading}>
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemPage - 1)} disabled={itemPage === 1 || itemSearchLoading}>
<ChevronLeft className="h-3 w-3" />
</Button>
<input type="text" inputMode="numeric" value={itemPageInput}
onChange={(e) => setItemPageInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }}
onBlur={commitItemPageInput}
onFocus={(e) => e.target.select()}
className="h-7 w-10 rounded-md border px-1 text-center text-xs" />
<span className="text-xs text-muted-foreground">/ {itemTotalPages || 1}</span>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemPage + 1)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
<ChevronRight className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemTotalPages)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
<span className="text-xs text-muted-foreground"> {itemTotal}</span>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<span className="text-sm text-muted-foreground">{itemSelectedMap.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
<Button variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemSelectedMap.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemSelectedMap.size}
</Button>
</div>
</div>

View File

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

View File

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

View File

@ -54,7 +54,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
{
key: "company_code",
label: "회사코드",
width: "150px",
width: "12%",
render: (value) => <span className="font-mono">{value}</span>,
},
{
@ -65,11 +65,12 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
{
key: "writer",
label: "등록자",
width: "200px",
width: "15%",
},
{
key: "diskUsage",
label: "디스크 사용량",
width: "15%",
hideOnMobile: true,
render: (_value, row) => formatDiskUsage(row),
},
@ -99,7 +100,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
cardSubtitle={(c) => <span className="font-mono">{c.company_code}</span>}
cardFields={cardFields}
actionsLabel="작업"
actionsWidth="180px"
actionsWidth="12%"
tableContainerClassName="!block"
cardContainerClassName="!hidden"
renderActions={(company) => (
<>
<Button

View File

@ -24,7 +24,7 @@ export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProp
</div>
{/* 오른쪽: 등록 버튼 */}
<Button onClick={onCreateClick} className="h-10 gap-2 text-sm font-medium">
<Button onClick={onCreateClick} className="h-10 w-full gap-2 text-sm font-medium lg:w-auto">
<Plus className="h-4 w-4" />
</Button>

View File

@ -15,7 +15,7 @@ interface DiskUsageSummaryProps {
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
if (!diskUsageInfo) {
return (
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="rounded-lg border bg-card p-4 sm:p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
@ -46,7 +46,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
const lastCheckedDate = new Date(lastChecked);
return (
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="rounded-lg border bg-card p-4 sm:p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
@ -64,7 +64,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
</Button>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{/* 총 회사 수 */}
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4 text-primary" />

View File

@ -108,11 +108,11 @@ function SortableHeaderCell({
style={style}
className={cn(col.width, col.minWidth, "select-none relative")}
>
<div className="flex items-center gap-0.5">
<div className="inline-flex items-center gap-1">
<div
{...attributes}
{...listeners}
className="flex items-center gap-0.5 cursor-pointer flex-1 min-w-0"
className="flex items-center gap-0.5 cursor-pointer min-w-0"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
@ -366,7 +366,6 @@ export function DataGrid({
row[colKey] = editValue;
toast.success("저장됨");
} catch (err) {
console.error("셀 저장 실패:", err);
toast.error("저장에 실패했습니다.");
setEditingCell(null);
return;

View File

@ -31,6 +31,8 @@ interface FullscreenDialogProps {
/** 기본 모달 너비 (기본: "w-[95vw]") */
defaultWidth?: string;
className?: string;
/** children wrapper에 추가할 className (기본: "overflow-auto") — "overflow-hidden"으로 변경하면 내부 flex 레이아웃이 고정 높이 내에서 동작 */
contentClassName?: string;
}
export function FullscreenDialog({
@ -38,6 +40,7 @@ export function FullscreenDialog({
defaultMaxWidth = "max-w-5xl",
defaultWidth = "w-[95vw]",
className,
contentClassName,
}: FullscreenDialogProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
@ -73,7 +76,7 @@ export function FullscreenDialog({
</div>
</DialogHeader>
<div className="flex-1 overflow-auto">
<div className={cn("flex-1", contentClassName || "overflow-auto")}>
{children}
</div>

View File

@ -0,0 +1,122 @@
"use client";
/**
* SmartSelect
*
* .
* - 5 미만: 기본 Select ()
* - 5 이상: Combobox ( + )
*/
import React, { useState, useMemo } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
const SEARCH_THRESHOLD = 5;
export interface SmartSelectOption {
code: string;
label: string;
}
interface SmartSelectProps {
options: SmartSelectOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function SmartSelect({
options,
value,
onValueChange,
placeholder = "선택",
disabled = false,
className,
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const selectedLabel = useMemo(
() => options.find((o) => o.code === value)?.label,
[options, value],
);
if (options.length < SEARCH_THRESHOLD) {
return (
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className={cn("h-9", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("h-9 w-full justify-between font-normal", className)}
>
<span className="truncate">
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command
filter={(val, search) => {
if (!search) return 1;
return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o.code}
value={o.label}
onSelect={() => {
onValueChange(o.code);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === o.code ? "opacity-100" : "opacity-0",
)}
/>
{o.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -50,6 +50,7 @@ export interface PurchaseOrderSource {
unit_price: number;
status: string;
due_date: string | null;
source_table: string;
}
export interface ShipmentSource {
@ -156,24 +157,30 @@ export async function getReceivingWarehouses() {
return res.data as { success: boolean; data: WarehouseOption[] };
}
// 소스 데이터 조회
export async function getPurchaseOrderSources(keyword?: string) {
// 소스 데이터 조회 (페이징)
interface SourceParams {
keyword?: string;
page?: number;
pageSize?: number;
}
export async function getPurchaseOrderSources(params?: SourceParams) {
const res = await apiClient.get("/receiving/source/purchase-orders", {
params: keyword ? { keyword } : {},
params: params || {},
});
return res.data as { success: boolean; data: PurchaseOrderSource[] };
return res.data as { success: boolean; data: PurchaseOrderSource[]; totalCount: number };
}
export async function getShipmentSources(keyword?: string) {
export async function getShipmentSources(params?: SourceParams) {
const res = await apiClient.get("/receiving/source/shipments", {
params: keyword ? { keyword } : {},
params: params || {},
});
return res.data as { success: boolean; data: ShipmentSource[] };
return res.data as { success: boolean; data: ShipmentSource[]; totalCount: number };
}
export async function getItemSources(keyword?: string) {
export async function getItemSources(params?: SourceParams) {
const res = await apiClient.get("/receiving/source/items", {
params: keyword ? { keyword } : {},
params: params || {},
});
return res.data as { success: boolean; data: ItemSource[] };
return res.data as { success: boolean; data: ItemSource[]; totalCount: number };
}

View File

@ -11,6 +11,7 @@ import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
import { useAuth } from "@/hooks/useAuth";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
import {
Upload,
File,
@ -64,7 +65,6 @@ const getFileIcon = (extension: string) => {
export interface FileUploadComponentProps {
component: any;
componentConfig: FileUploadConfig;
componentStyle: React.CSSProperties;
className: string;
isInteractive: boolean;
isDesignMode: boolean;
@ -82,7 +82,6 @@ export interface FileUploadComponentProps {
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
component,
componentConfig,
componentStyle,
className,
isInteractive,
isDesignMode = false, // 기본값 설정
@ -187,7 +186,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
// silently ignore
}
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
@ -259,7 +258,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
filesLoadedFromObjidRef.current = true;
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
// silently ignore
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
@ -287,7 +286,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(newFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
// silently ignore
}
// 전역 상태 업데이트 (🆕 고유 키 사용)
@ -346,11 +345,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도
if (!screenId) {
console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", {
componentId: component.id,
pathname: window.location.pathname,
formData: formData,
});
// screenId를 0으로 설정하여 컴포넌트 ID로만 조회
screenId = 0;
}
@ -400,7 +394,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
finalFiles = [...formattedFiles, ...additionalFiles];
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
// silently ignore
}
setUploadedFiles(finalFiles);
@ -424,13 +418,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
try {
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
// silently ignore
}
}
return true; // 새로운 로직 사용됨
}
} catch (error) {
console.error("파일 조회 오류:", error);
// silently ignore
}
return false; // 기존 로직 사용
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
@ -503,7 +497,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
// silently ignore
}
}
};
@ -690,11 +684,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}));
allNewFiles.push(...chunkFiles);
} else {
console.error(`${chunkIndex + 1}번째 배치 업로드 실패:`, response);
failedChunks++;
}
} catch (chunkError) {
console.error(`${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError);
failedChunks++;
}
}
@ -714,7 +706,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
// silently ignore
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
@ -752,8 +744,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
});
} else {
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
@ -797,7 +787,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.success(`${allNewFiles.length}개 파일 업로드 완료`);
}
} catch (error) {
console.error("파일 업로드 오류:", error);
setUploadStatus("error");
toast.dismiss("file-upload");
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
@ -828,7 +817,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
});
toast.success(`${file.realFileName} 다운로드 완료`);
} catch (error) {
console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드에 실패했습니다.");
}
}, []);
@ -851,7 +839,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
// silently ignore
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
@ -903,7 +891,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error("파일 삭제 오류:", error);
toast.error("파일 삭제에 실패했습니다.");
}
},
@ -925,7 +912,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// objid가 없거나 유효하지 않으면 로드 중단
if (!file.objid || file.objid === "0" || file.objid === "") {
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
setRepresentativeImageUrl(null);
return;
}
@ -950,11 +936,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setRepresentativeImageUrl(url);
} catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
objid: file.objid,
error: error?.response?.status || error?.message,
});
setRepresentativeImageUrl(null);
}
},
@ -980,7 +961,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 대표 이미지 로드
loadRepresentativeImage(file);
} catch (e) {
console.error("❌ 대표 파일 설정 실패:", e);
// silently ignore
}
},
[uploadedFiles, component.id, loadRepresentativeImage]
@ -1050,25 +1031,53 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
);
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값)
// 🔧 커스텀 스타일 감지 및 추출 (StyleEditor에서 설정한 값, component.style에서 직접 읽기)
const customStyle = component.style || {};
const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border);
const hasCustomBackground = !!customStyle.backgroundColor;
const hasCustomRadius = !!customStyle.borderRadius;
// 커스텀 border inline style 구축
const customBorderStyle: React.CSSProperties = hasCustomBorder
? {
...(customStyle.border
? { border: customStyle.border }
: {
borderWidth: customStyle.borderWidth || "1px",
borderStyle: customStyle.borderStyle || "solid",
borderColor: customStyle.borderColor,
}),
}
: {};
// 커스텀 배경/radius inline style
const customBackgroundStyle: React.CSSProperties = hasCustomBackground
? { backgroundColor: customStyle.backgroundColor }
: {};
const customRadiusStyle: React.CSSProperties = hasCustomRadius
? { borderRadius: customStyle.borderRadius }
: {};
// 커스텀 텍스트 style (내부 텍스트 요소에 전파)
const customTextStyle: React.CSSProperties = {
...(customStyle.color ? { color: customStyle.color } : {}),
...(customStyle.fontSize ? { fontSize: customStyle.fontSize } : {}),
...(customStyle.fontWeight ? { fontWeight: customStyle.fontWeight } : {}),
};
const hasCustomText = Object.keys(customTextStyle).length > 0;
return (
<div
ref={containerRef}
style={{
...componentStyle,
width: "100%",
height: "100%",
border: hasCustomBorder ? undefined : "none",
boxShadow: "none",
outline: "none",
backgroundColor: hasCustomBackground ? undefined : "transparent",
padding: "0px",
borderRadius: hasCustomRadius ? undefined : "0px",
border: "none",
backgroundColor: "transparent",
borderRadius: "0px",
marginBottom: "8px",
}}
className={`${className} file-upload-container`}
@ -1081,7 +1090,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
top: "-20px",
left: "0px",
fontSize: customStyle.labelFontSize || "12px",
color: customStyle.labelColor || "rgb(107, 114, 128)",
color: getAdaptiveLabelColor(customStyle.labelColor || "rgb(107, 114, 128)"),
fontWeight: customStyle.labelFontWeight || "400",
background: "transparent",
border: "none",
@ -1106,6 +1115,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 커스텀 배경이 없을 때만 기본 배경 표시
!hasCustomBackground && "bg-card",
)}
style={{
...(hasCustomBorder ? { ...customBorderStyle, ...customRadiusStyle } : {}),
...(hasCustomBackground ? customBackgroundStyle : {}),
...(hasCustomRadius && !hasCustomBorder ? customRadiusStyle : {}),
}}
>
{/* 대표 이미지 전체 화면 표시 */}
{uploadedFiles.length > 0 ? (() => {
@ -1155,7 +1169,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
</>
);
})() : (
<div className="flex h-full w-full flex-col items-center justify-center text-muted-foreground">
<div
className={cn("flex h-full w-full flex-col items-center justify-center", !hasCustomText && "text-muted-foreground")}
style={hasCustomText ? customTextStyle : undefined}
>
<File className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p>
<Button

View File

@ -33,6 +33,7 @@ import { formatNumber as centralFormatNumber } from "@/lib/formatting";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
import { codeCache } from "@/lib/caching/codeCache";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
Dialog,
@ -402,6 +403,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [columnCodeCategories, setColumnCodeCategories] = useState<Record<string, string>>({}); // columnName → codeCategory
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 🆕 페이징 상태
@ -1124,6 +1126,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return <SplitPanelCellImage value={String(value)} />;
}
// code 타입: code_value → code_name 변환
if (colInputType === "code") {
const codeCategory = columnCodeCategories[columnName];
if (codeCategory && value) {
try {
const syncResult = codeCache.getCodeSync(codeCategory);
if (syncResult && Array.isArray(syncResult)) {
const foundCode = syncResult.find(
(item: any) => String(item.code_value).toUpperCase() === String(value).toUpperCase(),
);
if (foundCode) {
return foundCode.code_name;
}
} else {
// 캐시 미스: 비동기 로딩 트리거
codeCache.getCodeAsync(codeCategory).catch(() => {});
}
} catch {
// ignore
}
}
}
// 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
@ -1156,20 +1181,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (mapping && mapping[strValue]) {
const categoryData = mapping[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
return categoryData.label || strValue;
}
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
@ -1178,19 +1190,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const m = categoryMappings[key];
if (m && m[strValue]) {
const categoryData = m[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
return categoryData.label || strValue;
}
}
}
@ -1216,7 +1216,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue, columnInputTypes],
[formatDateValue, formatNumberValue, columnInputTypes, columnCodeCategories],
);
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
@ -2218,6 +2218,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
loadLeftColumnLabels();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 왼쪽 테이블 inputTypes + codeCategory 로드
useEffect(() => {
const loadLeftColumnInputTypes = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const inputTypes: Record<string, string> = {};
const codeCategories: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
if (col.codeCategory) {
codeCategories[colName] = col.codeCategory;
}
}
});
setColumnInputTypes((prev) => ({ ...prev, ...inputTypes }));
setColumnCodeCategories((prev) => ({ ...prev, ...codeCategories }));
} catch {
// ignore
}
};
loadLeftColumnInputTypes();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// codeCategory 프리로딩 (캐시 미스 방지)
useEffect(() => {
const categories = Object.values(columnCodeCategories).filter(Boolean);
if (categories.length > 0) {
codeCache.preloadCodes([...new Set(categories)]).catch(() => {});
}
}, [columnCodeCategories]);
// 우측 테이블 컬럼 정보 로드
useEffect(() => {
const loadRightTableColumns = async () => {
@ -2247,20 +2284,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
});
const inputTypes: Record<string, string> = {};
const codeCategories: Record<string, string> = {};
for (const tbl of tablesToLoad) {
try {
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
inputTypesResponse.forEach((col: any) => {
const tblColumnsResponse = await tableTypeApi.getColumns(tbl);
tblColumnsResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
if (col.codeCategory) {
codeCategories[colName] = col.codeCategory;
}
}
});
} catch {
// ignore
}
}
setColumnInputTypes(inputTypes);
setColumnInputTypes((prev) => ({ ...prev, ...inputTypes }));
setColumnCodeCategories((prev) => ({ ...prev, ...codeCategories }));
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
@ -2304,12 +2346,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategories = (items: any[]) => {
items.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
if (item.children && item.children.length > 0) {
flattenCategories(item.children);
}
});
};
flattenCategories(response.data.data);
// 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장
const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`;
@ -2391,19 +2439,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategories = (items: any[]) => {
items.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
if (item.children && item.children.length > 0) {
flattenCategories(item.children);
}
});
};
flattenCategories(response.data.data);
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
// 기존 매핑이 있으면 병합, 없으면 새로 생성
// 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
}
} catch (error) {

View File

@ -228,7 +228,7 @@ const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => {
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("파일 다운로드 오류:", err);
// silently ignore
}
};
@ -708,7 +708,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
// silently ignore
}
}
}
@ -1157,7 +1157,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 총 아이템 수 업데이트
setTotalItems(newData.length);
} catch (error) {
console.error("데이터 수신 실패:", error);
throw error;
}
},
@ -1434,7 +1433,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
}
} catch (error) {
console.error("❌ 컬럼 순서 파싱 실패:", error);
// silently ignore
}
}
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
@ -1522,7 +1521,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
// silently ignore
}
}, [tableConfig.selectedTable]);
@ -1557,7 +1556,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
} catch (error) {
console.error("테이블 라벨 가져오기 실패:", error);
// silently ignore
}
}, [tableConfig.selectedTable]);
@ -2036,13 +2035,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
const uniqueItemNumbers = [...new Set(itemNumbers)];
// console.log("✅ [TableList] API 응답 받음");
// console.log(` - dataLength: ${response.data?.length || 0}`);
// console.log(` - total: ${response.total}`);
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
@ -2074,7 +2066,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
setTotalPages(0);
setTotalItems(0);
@ -2266,8 +2257,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
},
);
}
} else {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
}
};
@ -2709,7 +2698,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const primaryKeyValue = row[primaryKeyField];
if (primaryKeyValue === undefined || primaryKeyValue === null) {
console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField);
cancelEditing();
return;
}
@ -3215,7 +3203,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
// silently ignore
}
}, [
tableStateKey,
@ -3261,7 +3249,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setHeaderFilters(filters);
}
} catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error);
// silently ignore
}
}, [tableStateKey]);
@ -3281,7 +3269,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setHeaderFilters({});
toast.success("테이블 설정이 초기화되었습니다.");
} catch (error) {
console.error("❌ 테이블 상태 초기화 실패:", error);
// silently ignore
}
}, [tableStateKey]);
@ -3993,7 +3981,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요.");
}
} catch (error) {
console.error("❌ PDF 내보내기 실패:", error);
showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
}
},
@ -4072,41 +4059,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const lastColumnOrderRef = useRef<string>("");
useEffect(() => {
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
// hasCallback: !!onSelectedRowsChange,
// visibleColumnsLength: visibleColumns.length,
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
// });
if (!onSelectedRowsChange) {
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
return;
}
if (visibleColumns.length === 0) {
// console.warn("⚠️ visibleColumns가 비어있습니다!");
return;
}
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
const columnOrderString = currentColumnOrder.join(",");
// console.log("🔍 [컬럼 순서] 비교:", {
// current: columnOrderString,
// last: lastColumnOrderRef.current,
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
// });
if (columnOrderString === lastColumnOrderRef.current) {
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
return;
}
lastColumnOrderRef.current = columnOrderString;
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
// 선택된 행 데이터 가져오기
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -4597,7 +4567,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return convertedValue;
}
} catch (error) {
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
// silently ignore
}
// 변환 실패 시 원본 코드 값 반환
return String(value);
@ -4706,7 +4676,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setVisibleFilterColumns(new Set());
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setVisibleFilterColumns(new Set());
}
}, [filterSettingKey, visibleColumns]);
@ -4723,7 +4692,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 검색 값 초기화
setSearchValues({});
} catch (error) {
console.error("필터 설정 저장 실패:", error);
showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
}
}, [filterSettingKey, visibleFilterColumns]);
@ -4773,7 +4741,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
// silently ignore
}
}, [groupSettingKey, groupByColumns]);
@ -5041,7 +5009,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
// silently ignore
}
}, [groupSettingKey, visibleColumns]);
@ -5055,14 +5023,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
useEffect(() => {
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
// isDesignMode,
// tableName: tableConfig.selectedTable,
// currentPage,
// sortColumn,
// sortDirection,
// });
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
@ -5288,7 +5248,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
savedWidths = JSON.parse(saved);
}
} catch (error) {
console.error("컬럼 너비 불러오기 실패:", error);
// silently ignore
}
}
@ -6057,7 +6017,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
isColumnDropTarget && "border-l-primary border-l-4",
)}
style={{
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
textAlign: column.columnName === "__checkbox__" ? "center" : "left",
width:
column.columnName === "__checkbox__"
? "48px"
@ -6086,7 +6046,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: "4px" }}>
{isColumnDragEnabled && (
<GripVertical className="absolute left-0.5 top-1/2 h-3 w-3 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-40" />
)}
@ -6280,7 +6240,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
localStorage.setItem(storageKey, JSON.stringify(newWidths));
} catch (error) {
console.error("컬럼 너비 저장 실패:", error);
// silently ignore
}
}
@ -7011,7 +6971,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.success("행이 삭제되었습니다");
handleRefresh();
} catch (error) {
console.error("삭제 오류:", error);
toast.error("삭제 중 오류가 발생했습니다");
}
}

View File

@ -41,6 +41,7 @@ export interface UserDepartmentMapping {
export interface DepartmentFormData {
dept_name: string; // 부서명 (필수)
parent_dept_code?: string | null; // 상위 부서 코드
dept_code?: string; // 채번 시스템으로 할당된 부서코드 (선택)
}
// 부서 트리 노드 (UI용)