Merge pull request 'jskim-node' (#432) from jskim-node into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/432
This commit is contained in:
kjs 2026-03-30 11:59:14 +09:00
commit 21c507d9f7
38 changed files with 1769 additions and 553 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

@ -62,6 +62,7 @@ export class AuthController {
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
let firstMenuName: string | null = null;
try {
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
@ -74,7 +75,8 @@ export class AuthController {
if (firstMenu) {
firstMenuPath = firstMenu.menu_url || firstMenu.url;
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
firstMenuName = firstMenu.menu_name_kor || firstMenu.translated_name || firstMenu.menu_name || null;
logger.debug(`첫 번째 메뉴: ${firstMenuPath} (${firstMenuName})`);
} else {
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
}
@ -112,6 +114,7 @@ export class AuthController {
userInfo,
token: loginResult.token,
firstMenuPath,
firstMenuName,
popLandingPath,
},
});

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

@ -2833,17 +2833,19 @@ export class TableManagementService {
.join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
const hasIdColumn = columnTypeMap.has("id");
const returningClause = hasIdColumn ? "RETURNING id" : "RETURNING *";
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING id
${returningClause}
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
const insertedId = insertResult?.[0]?.id ?? insertResult?.[0]?.[columns[0]] ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);

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("삭제에 실패했습니다."); }
@ -225,6 +267,7 @@ export default function DepartmentPage() {
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
@ -240,10 +283,10 @@ export default function DepartmentPage() {
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
@ -373,8 +416,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>
@ -424,12 +468,12 @@ export default function DepartmentPage() {
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
placeholder="사번" className="h-9" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -437,7 +481,7 @@ export default function DepartmentPage() {
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>

View File

@ -49,9 +49,9 @@ const FORM_FIELDS = [
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
@ -483,7 +483,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
className="h-9"
/>

View File

@ -81,6 +81,10 @@ export default function WorkInstructionPage() {
const [confirmWorker, setConfirmWorker] = useState("");
const [saving, setSaving] = useState(false);
// 등록 확인 모달 — 인라인 추가 폼
const [confirmAddQty, setConfirmAddQty] = useState("");
const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false);
// 수정 모달
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editOrder, setEditOrder] = useState<any>(null);
@ -217,6 +221,18 @@ export default function WorkInstructionPage() {
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
};
// 등록 확인 모달 — 인라인 품목 추가
const addConfirmItem = () => {
if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; }
const firstItem = confirmItems[0];
setConfirmItems(prev => [...prev, {
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
qty: Number(confirmAddQty), remark: "",
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
}]);
setConfirmAddQty("");
};
// ─── 2단계 최종 적용 ───
const finalizeRegistration = async () => {
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead></TableRow>
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader>
<TableBody>
{confirmItems.map((item, idx) => (
@ -640,6 +656,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
))}
</TableBody>
@ -711,19 +728,6 @@ export default function WorkInstructionPage() {
</div>
</div>
{/* 인라인 추가 폼 */}
<div className="border rounded-lg p-4 bg-muted/20">
<div className="flex items-end gap-3 flex-wrap">
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"> <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"></SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label>
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
</div>
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> </Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">

View File

@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -52,10 +52,12 @@ const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "division", label: "거래유형", width: "w-[80px]" },
{ key: "contact_person", label: "담당자", width: "w-[80px]" },
{ key: "contact_person", label: "거래처담당자", width: "w-[90px]" },
{ key: "internal_manager", label: "사내담당자", width: "w-[90px]" },
{ key: "contact_phone", label: "전화번호", width: "w-[110px]" },
{ key: "business_number", label: "사업자번호", width: "w-[110px]" },
{ key: "email", label: "이메일", width: "w-[130px]" },
{ key: "address", label: "주소", minWidth: "min-w-[150px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
@ -79,10 +81,12 @@ export default function CustomerManagementPage() {
// 좌측: 거래처 목록
const [customers, setCustomers] = useState<any[]>([]);
const [rawCustomers, setRawCustomers] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [customerCount, setCustomerCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(LEFT_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
@ -96,6 +100,7 @@ export default function CustomerManagementPage() {
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
const [editItemData, setEditItemData] = useState<any>(null);
const savingRef = useRef(false);
const [deliveryLoading, setDeliveryLoading] = useState(false);
// 모달
@ -138,6 +143,8 @@ export default function CustomerManagementPage() {
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사원 목록 (사내담당자 선택용)
const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]);
// 카테고리 로드
useEffect(() => {
@ -170,9 +177,33 @@ export default function CustomerManagementPage() {
setPriceCategoryOptions(priceOpts);
};
load();
// 사원 목록 로드
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
})));
}).catch(() => {});
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of LEFT_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS);
// 필터 설정
setFilterConfig(settings.filters);
}, []);
@ -192,6 +223,8 @@ export default function CustomerManagementPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// raw 데이터 보관 (수정 시 원본 카테고리 코드 사용)
setRawCustomers(raw);
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
@ -201,6 +234,9 @@ export default function CustomerManagementPage() {
...r,
division: resolve("division", r.division),
status: resolve("status", r.status),
internal_manager: r.internal_manager
? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
: "",
}));
setCustomers(data);
setCustomerCount(res.data?.data?.total || raw.length);
@ -210,7 +246,7 @@ export default function CustomerManagementPage() {
} finally {
setCustomerLoading(false);
}
}, [searchFilters, categoryOptions]);
}, [searchFilters, categoryOptions, employeeOptions]);
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
@ -334,7 +370,9 @@ export default function CustomerManagementPage() {
const openCustomerEdit = () => {
if (!selectedCustomer) return;
setCustomerForm({ ...selectedCustomer });
// raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터)
const rawData = rawCustomers.find((c) => c.id === selectedCustomerId);
setCustomerForm({ ...(rawData || selectedCustomer) });
setFormErrors({});
setCustomerEditMode(true);
setCustomerModalOpen(true);
@ -365,13 +403,18 @@ export default function CustomerManagementPage() {
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm;
// 빈 문자열을 null로 변환 (DB 타입 호환)
const cleanFields: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
cleanFields[key] = value === "" ? null : value;
}
if (customerEditMode && id) {
await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, {
originalData: { id }, updatedData: fields,
originalData: { id }, updatedData: cleanFields,
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields);
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields);
toast.success("등록되었습니다.");
}
setCustomerModalOpen(false);
@ -569,6 +612,8 @@ export default function CustomerManagementPage() {
const handleItemDetailSave = async () => {
if (!selectedCustomer) return;
if (savingRef.current) return;
savingRef.current = true;
const isEditingExisting = !!editItemData;
setSaving(true);
try {
@ -618,13 +663,28 @@ export default function CustomerManagementPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록 모드
// 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크
if (!mappingRows.length || !mappingRows[0]?.customer_item_code) {
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) {
toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`);
continue;
}
}
let mappingId: string | null = null;
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: selectedCustomer.customer_code, item_id: itemKey,
@ -650,6 +710,7 @@ export default function CustomerManagementPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@ -669,6 +730,7 @@ export default function CustomerManagementPage() {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
savingRef.current = false;
}
};
@ -773,9 +835,10 @@ export default function CustomerManagementPage() {
// 셀렉트 렌더링
const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<Select value={value || "__none__"} onValueChange={(v) => onChange(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(categoryOptions[field] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
@ -836,7 +899,7 @@ export default function CustomerManagementPage() {
</div>
<DataGrid
gridId="customer-left"
columns={LEFT_COLUMNS}
columns={gridColumns}
data={customers}
loading={customerLoading}
selectedId={selectedCustomerId}
@ -952,9 +1015,23 @@ export default function CustomerManagementPage() {
{renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"></Label>
<Input value={customerForm.contact_person || ""} onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="담당자" className="h-9" />
placeholder="거래처담당자" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={customerForm.internal_manager || "__none__"} onValueChange={(v) => setCustomerForm((p) => ({ ...p, internal_manager: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="사내담당자 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{employeeOptions.map((emp) => (
<SelectItem key={emp.user_id} value={emp.user_id}>
{emp.user_name}{emp.position_name ? ` (${emp.position_name})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -1106,7 +1183,14 @@ export default function CustomerManagementPage() {
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
) : (<>
<div className="flex gap-2 items-center text-[10px] text-muted-foreground font-medium">
<span className="w-4 shrink-0"></span>
<span className="flex-1"> </span>
<span className="flex-1"> </span>
<span className="w-7 shrink-0"></span>
</div>
{mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
@ -1121,6 +1205,7 @@ export default function CustomerManagementPage() {
</Button>
</div>
))}
</>)}
</div>
</div>

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,13 @@ 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 [itemSearchDivision, setItemSearchDivision] = useState("all");
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 +230,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 +273,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 +357,6 @@ export default function SalesOrderPage() {
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
@ -377,7 +403,6 @@ export default function SalesOrderPage() {
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
@ -433,7 +458,6 @@ export default function SalesOrderPage() {
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
@ -441,26 +465,66 @@ 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[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
if (itemSearchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision });
} else {
// 기본: 영업관련 division만 (판매품, 제품, 영업관리 등)
const salesDivCodes = (categoryOptions["item_division"] || [])
.filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label)))
.map((o) => o.code);
if (salesDivCodes.length > 0) {
filters.push({ columnName: "division", operator: "in", value: salesDivCodes });
}
}
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 +556,7 @@ export default function SalesOrderPage() {
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
// 단가 조회 실패 시 무시
}
}
@ -516,15 +580,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 +721,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 +738,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 +773,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 +806,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 +841,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 +851,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 +883,35 @@ 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">
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<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,32 +925,72 @@ 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>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
<TableCell className="text-xs">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-xs">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</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

@ -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("삭제에 실패했습니다."); }
@ -225,6 +267,7 @@ export default function DepartmentPage() {
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
@ -240,10 +283,10 @@ export default function DepartmentPage() {
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
@ -373,8 +416,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>
@ -424,12 +468,12 @@ export default function DepartmentPage() {
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
placeholder="사번" className="h-9" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -437,7 +481,7 @@ export default function DepartmentPage() {
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>

View File

@ -49,9 +49,9 @@ const FORM_FIELDS = [
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
@ -483,7 +483,7 @@ export default function ItemInfoPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)}
disabled={field.disabled && !isEditMode}
className="h-9"
/>

View File

@ -81,6 +81,10 @@ export default function WorkInstructionPage() {
const [confirmWorker, setConfirmWorker] = useState("");
const [saving, setSaving] = useState(false);
// 등록 확인 모달 — 인라인 추가 폼
const [confirmAddQty, setConfirmAddQty] = useState("");
const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false);
// 수정 모달
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editOrder, setEditOrder] = useState<any>(null);
@ -217,6 +221,18 @@ export default function WorkInstructionPage() {
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
};
// 등록 확인 모달 — 인라인 품목 추가
const addConfirmItem = () => {
if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; }
const firstItem = confirmItems[0];
setConfirmItems(prev => [...prev, {
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
qty: Number(confirmAddQty), remark: "",
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
}]);
setConfirmAddQty("");
};
// ─── 2단계 최종 적용 ───
const finalizeRegistration = async () => {
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead></TableRow>
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader>
<TableBody>
{confirmItems.map((item, idx) => (
@ -640,6 +656,7 @@ export default function WorkInstructionPage() {
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
))}
</TableBody>
@ -711,19 +728,6 @@ export default function WorkInstructionPage() {
</div>
</div>
{/* 인라인 추가 폼 */}
<div className="border rounded-lg p-4 bg-muted/20">
<div className="flex items-end gap-3 flex-wrap">
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"> <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"></SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label>
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
</div>
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> </Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">

View File

@ -12,7 +12,7 @@
* - (delivery_destination)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -52,10 +52,12 @@ const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "division", label: "거래유형", width: "w-[80px]" },
{ key: "contact_person", label: "담당자", width: "w-[80px]" },
{ key: "contact_person", label: "거래처담당자", width: "w-[90px]" },
{ key: "internal_manager", label: "사내담당자", width: "w-[90px]" },
{ key: "contact_phone", label: "전화번호", width: "w-[110px]" },
{ key: "business_number", label: "사업자번호", width: "w-[110px]" },
{ key: "email", label: "이메일", width: "w-[130px]" },
{ key: "address", label: "주소", minWidth: "min-w-[150px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
@ -79,10 +81,12 @@ export default function CustomerManagementPage() {
// 좌측: 거래처 목록
const [customers, setCustomers] = useState<any[]>([]);
const [rawCustomers, setRawCustomers] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [customerCount, setCustomerCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(LEFT_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
@ -96,6 +100,7 @@ export default function CustomerManagementPage() {
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
const [editItemData, setEditItemData] = useState<any>(null);
const savingRef = useRef(false);
const [deliveryLoading, setDeliveryLoading] = useState(false);
// 모달
@ -138,6 +143,8 @@ export default function CustomerManagementPage() {
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사원 목록 (사내담당자 선택용)
const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]);
// 카테고리 로드
useEffect(() => {
@ -170,9 +177,33 @@ export default function CustomerManagementPage() {
setPriceCategoryOptions(priceOpts);
};
load();
// 사원 목록 로드
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
})));
}).catch(() => {});
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of LEFT_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS);
// 필터 설정
setFilterConfig(settings.filters);
}, []);
@ -192,6 +223,8 @@ export default function CustomerManagementPage() {
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// raw 데이터 보관 (수정 시 원본 카테고리 코드 사용)
setRawCustomers(raw);
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
@ -201,6 +234,9 @@ export default function CustomerManagementPage() {
...r,
division: resolve("division", r.division),
status: resolve("status", r.status),
internal_manager: r.internal_manager
? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
: "",
}));
setCustomers(data);
setCustomerCount(res.data?.data?.total || raw.length);
@ -210,7 +246,7 @@ export default function CustomerManagementPage() {
} finally {
setCustomerLoading(false);
}
}, [searchFilters, categoryOptions]);
}, [searchFilters, categoryOptions, employeeOptions]);
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
@ -334,7 +370,9 @@ export default function CustomerManagementPage() {
const openCustomerEdit = () => {
if (!selectedCustomer) return;
setCustomerForm({ ...selectedCustomer });
// raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터)
const rawData = rawCustomers.find((c) => c.id === selectedCustomerId);
setCustomerForm({ ...(rawData || selectedCustomer) });
setFormErrors({});
setCustomerEditMode(true);
setCustomerModalOpen(true);
@ -365,13 +403,18 @@ export default function CustomerManagementPage() {
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm;
// 빈 문자열을 null로 변환 (DB 타입 호환)
const cleanFields: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
cleanFields[key] = value === "" ? null : value;
}
if (customerEditMode && id) {
await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, {
originalData: { id }, updatedData: fields,
originalData: { id }, updatedData: cleanFields,
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields);
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields);
toast.success("등록되었습니다.");
}
setCustomerModalOpen(false);
@ -569,6 +612,8 @@ export default function CustomerManagementPage() {
const handleItemDetailSave = async () => {
if (!selectedCustomer) return;
if (savingRef.current) return;
savingRef.current = true;
const isEditingExisting = !!editItemData;
setSaving(true);
try {
@ -618,13 +663,28 @@ export default function CustomerManagementPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록 모드
// 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크
if (!mappingRows.length || !mappingRows[0]?.customer_item_code) {
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) {
toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`);
continue;
}
}
let mappingId: string | null = null;
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: selectedCustomer.customer_code, item_id: itemKey,
@ -650,6 +710,7 @@ export default function CustomerManagementPage() {
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null),
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
@ -669,6 +730,7 @@ export default function CustomerManagementPage() {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
savingRef.current = false;
}
};
@ -773,9 +835,10 @@ export default function CustomerManagementPage() {
// 셀렉트 렌더링
const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<Select value={value || "__none__"} onValueChange={(v) => onChange(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(categoryOptions[field] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
@ -836,7 +899,7 @@ export default function CustomerManagementPage() {
</div>
<DataGrid
gridId="customer-left"
columns={LEFT_COLUMNS}
columns={gridColumns}
data={customers}
loading={customerLoading}
selectedId={selectedCustomerId}
@ -952,9 +1015,23 @@ export default function CustomerManagementPage() {
{renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"></Label>
<Input value={customerForm.contact_person || ""} onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="담당자" className="h-9" />
placeholder="거래처담당자" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={customerForm.internal_manager || "__none__"} onValueChange={(v) => setCustomerForm((p) => ({ ...p, internal_manager: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="사내담당자 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{employeeOptions.map((emp) => (
<SelectItem key={emp.user_id} value={emp.user_id}>
{emp.user_name}{emp.position_name ? ` (${emp.position_name})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -1106,7 +1183,14 @@ export default function CustomerManagementPage() {
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
) : (<>
<div className="flex gap-2 items-center text-[10px] text-muted-foreground font-medium">
<span className="w-4 shrink-0"></span>
<span className="flex-1"> </span>
<span className="flex-1"> </span>
<span className="w-7 shrink-0"></span>
</div>
{mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
@ -1121,6 +1205,7 @@ export default function CustomerManagementPage() {
</Button>
</div>
))}
</>)}
</div>
</div>

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,13 @@ 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 [itemSearchDivision, setItemSearchDivision] = useState("all");
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 +230,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 +273,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 +357,6 @@ export default function SalesOrderPage() {
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
@ -377,7 +403,6 @@ export default function SalesOrderPage() {
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
@ -433,7 +458,6 @@ export default function SalesOrderPage() {
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
@ -441,26 +465,66 @@ 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[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
if (itemSearchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision });
} else {
// 기본: 영업관련 division만 (판매품, 제품, 영업관리 등)
const salesDivCodes = (categoryOptions["item_division"] || [])
.filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label)))
.map((o) => o.code);
if (salesDivCodes.length > 0) {
filters.push({ columnName: "division", operator: "in", value: salesDivCodes });
}
}
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 +556,7 @@ export default function SalesOrderPage() {
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
// 단가 조회 실패 시 무시
}
}
@ -516,15 +580,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 +721,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 +738,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 +773,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 +806,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 +841,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 +851,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 +883,35 @@ 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">
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<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,32 +925,72 @@ 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>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
<TableCell className="text-xs">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
<TableCell className="text-xs">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
</TableRow>
))}
</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

@ -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

@ -114,9 +114,13 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
}
logger.info("회사 전환 성공", { companyCode });
// 즉시 페이지 새로고침 (토큰이 이미 저장됨)
window.location.reload();
// 탭 스토어 초기화 + 메뉴명 캐시 제거
const { useTabStore } = await import("@/stores/tabStore");
useTabStore.getState().closeAllTabs();
localStorage.removeItem("currentMenuName");
// 메인 페이지로 이동 (이전 회사의 stale URL 방지)
window.location.href = "/";
} catch (error: any) {
logger.error("회사 전환 실패", error);
alert(error.message || "회사 전환 중 오류가 발생했습니다.");

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

@ -119,6 +119,31 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// === COMPANY_9 (제일그라스) ===
"/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }),
// === COMPANY_29 ===
"/COMPANY_29/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_29/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_29/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_29/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_29/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_29/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
@ -197,6 +222,30 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/COMPANY_7/design/task-management": () => import("@/app/(main)/COMPANY_7/design/task-management/page"),
// COMPANY_9 (제일그라스)
"/COMPANY_9/sales/order": () => import("@/app/(main)/COMPANY_9/sales/order/page"),
// COMPANY_29
"/COMPANY_29/master-data/item-info": () => import("@/app/(main)/COMPANY_29/master-data/item-info/page"),
"/COMPANY_29/master-data/department": () => import("@/app/(main)/COMPANY_29/master-data/department/page"),
"/COMPANY_29/sales/order": () => import("@/app/(main)/COMPANY_29/sales/order/page"),
"/COMPANY_29/sales/customer": () => import("@/app/(main)/COMPANY_29/sales/customer/page"),
"/COMPANY_29/sales/sales-item": () => import("@/app/(main)/COMPANY_29/sales/sales-item/page"),
"/COMPANY_29/sales/shipping-order": () => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"),
"/COMPANY_29/sales/shipping-plan": () => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"),
"/COMPANY_29/sales/claim": () => import("@/app/(main)/COMPANY_29/sales/claim/page"),
"/COMPANY_29/production/process-info": () => import("@/app/(main)/COMPANY_29/production/process-info/page"),
"/COMPANY_29/production/work-instruction": () => import("@/app/(main)/COMPANY_29/production/work-instruction/page"),
"/COMPANY_29/production/plan-management": () => import("@/app/(main)/COMPANY_29/production/plan-management/page"),
"/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"),
"/COMPANY_29/logistics/material-status": () => import("@/app/(main)/COMPANY_29/logistics/material-status/page"),
"/COMPANY_29/logistics/outbound": () => import("@/app/(main)/COMPANY_29/logistics/outbound/page"),
"/COMPANY_29/logistics/receiving": () => import("@/app/(main)/COMPANY_29/logistics/receiving/page"),
"/COMPANY_29/logistics/packaging": () => import("@/app/(main)/COMPANY_29/logistics/packaging/page"),
"/COMPANY_29/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"),
"/COMPANY_29/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"),
"/COMPANY_29/design/project": () => import("@/app/(main)/COMPANY_29/design/project/page"),
"/COMPANY_29/design/change-management": () => import("@/app/(main)/COMPANY_29/design/change-management/page"),
"/COMPANY_29/design/my-work": () => import("@/app/(main)/COMPANY_29/design/my-work/page"),
"/COMPANY_29/design/design-request": () => import("@/app/(main)/COMPANY_29/design/design-request/page"),
"/COMPANY_29/design/task-management": () => import("@/app/(main)/COMPANY_29/design/task-management/page"),
};
const DYNAMIC_ADMIN_PATTERNS: Array<{

View File

@ -250,7 +250,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (screenMatch) {
const screenId = parseInt(screenMatch[1]);
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid });
const savedMenuName = typeof window !== "undefined" ? localStorage.getItem("currentMenuName") : null;
store.openTab({ type: "screen", title: savedMenuName || `화면 ${screenId}`, screenId, menuObjid });
return;
}

View File

@ -115,6 +115,7 @@ export const useLogin = () => {
const result = await apiCall<{
token?: string;
firstMenuPath?: string;
firstMenuName?: string;
popLandingPath?: string;
}>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, {
userId: formData.userId,
@ -139,6 +140,10 @@ export const useLogin = () => {
}
} else {
const firstMenuPath = result.data?.firstMenuPath;
const firstMenuName = result.data?.firstMenuName;
if (firstMenuName) {
localStorage.setItem("currentMenuName", firstMenuName);
}
if (firstMenuPath) {
router.push(firstMenuPath);
} else {

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()); // 펼쳐진 항목들
// 🆕 페이징 상태
@ -1178,6 +1180,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");
@ -1210,20 +1235,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;
}
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
@ -1232,19 +1244,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;
}
}
}
@ -1270,7 +1270,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue, columnInputTypes],
[formatDateValue, formatNumberValue, columnInputTypes, columnCodeCategories],
);
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
@ -2272,6 +2272,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 () => {
@ -2301,20 +2338,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);
}
@ -2358,12 +2400,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}`;
@ -2445,19 +2493,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

@ -1,6 +1,7 @@
"use client";
import React from "react";
import ReactDOM from "react-dom";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
@ -38,8 +39,9 @@ interface SingleTableWithStickyProps {
onEditSave?: () => void;
editInputRef?: React.RefObject<HTMLInputElement | HTMLSelectElement>;
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
columnMeta?: Record<string, { inputType?: string }>;
columnMeta?: Record<string, { inputType?: string; detailSettings?: any }>;
categoryMappings?: Record<string, Record<string, { label: string }>>;
multiSelectColumns?: Set<string>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
@ -77,6 +79,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
editInputRef,
columnMeta,
categoryMappings,
multiSelectColumns,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
@ -331,6 +334,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground h-10 align-middle text-[11px] transition-colors",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
@ -391,6 +396,48 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
value,
label: info.label,
}));
// 다중선택 판별: 화면 모달 설정에서 multiple: true인 컬럼
const isMultiSelect = multiSelectColumns?.has(column.columnName) || false;
if (isMultiSelect) {
const selectedValues = (editingValue ?? "").split(",").filter(Boolean);
const toggleValue = (val: string) => {
const next = selectedValues.includes(val)
? selectedValues.filter((v: string) => v !== val)
: [...selectedValues, val];
onEditingValueChange?.(next.join(","));
};
// Portal로 body에 직접 렌더링 (overflow:hidden 우회)
const cellEl = document.querySelector(
`[data-row="${index}"][data-col="${colIndex}"]`
) as HTMLElement | null;
const rect = cellEl?.getBoundingClientRect();
const portalContent = rect ? ReactDOM.createPortal(
<div
data-multi-select-portal="true"
className="fixed min-w-[180px] max-h-[250px] overflow-auto rounded border border-primary bg-background p-1 shadow-xl"
style={{ top: rect.top - 4, left: rect.left, transform: "translateY(-100%)", zIndex: 99999 }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape" && onEditKeyDown) onEditKeyDown(e as any);
if (e.key === "Enter") handleBlurSave();
}}
onMouseDown={(e) => e.stopPropagation()}
>
{selectOptions.map((opt) => (
<label key={opt.value} className="flex items-center gap-1.5 px-2 py-1 text-xs cursor-pointer hover:bg-muted rounded whitespace-nowrap">
<input type="checkbox" checked={selectedValues.includes(opt.value)}
onChange={() => toggleValue(opt.value)} className="h-3 w-3" />
{opt.label}
</label>
))}
</div>,
document.body
) : null;
return <>{portalContent}<span className="text-xs text-muted-foreground">{selectedValues.length} </span></>;
}
return (
<select
ref={editInputRef as React.RefObject<HTMLSelectElement>}

View File

@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { tableTypeApi, screenApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
@ -228,7 +229,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 +709,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
// silently ignore
}
}
}
@ -873,7 +874,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string; detailSettings?: any }>
>({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
@ -1157,7 +1158,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 총 아이템 수 업데이트
setTotalItems(newData.length);
} catch (error) {
console.error("데이터 수신 실패:", error);
throw error;
}
},
@ -1434,7 +1434,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 추가 (데이터 로드 후 실행)
@ -1460,16 +1460,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string; detailSettings?: any }> = {};
const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
const detailSettingsMap: Record<string, any> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
if (col.detailSettings && typeof col.detailSettings === "object") {
detailSettingsMap[col.columnName] = col.detailSettings;
}
});
}
@ -1480,6 +1484,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
categoryRef: categoryRefMap[col.columnName],
detailSettings: detailSettingsMap[col.columnName],
};
});
@ -1493,11 +1498,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
const detailSettingsMap: Record<string, any> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
if (col.detailSettings && typeof col.detailSettings === "object") {
detailSettingsMap[col.columnName] = col.detailSettings;
}
});
tableColumnCache.set(cacheKey, {
@ -1507,7 +1516,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string; detailSettings?: any }> = {};
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
@ -1516,13 +1525,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
categoryRef: categoryRefMap[col.columnName],
detailSettings: detailSettingsMap[col.columnName],
};
});
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
// silently ignore
}
}, [tableConfig.selectedTable]);
@ -1557,7 +1567,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 +2046,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 +2077,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
setTotalPages(0);
setTotalItems(0);
@ -2266,8 +2268,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
},
);
}
} else {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
}
};
@ -2527,11 +2527,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 이전 다중선택 편집 중이면 먼저 저장
if (multiSelectPortalRef.current && editingCell) {
saveEditingRef.current?.();
}
setEditingCell({ rowIndex, colIndex, columnName, originalValue: value });
setEditingValue(value !== null && value !== undefined ? String(value) : "");
setFocusedCell({ rowIndex, colIndex });
},
[visibleColumns],
[visibleColumns, editingCell],
);
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
@ -2562,6 +2567,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
// 모달 화면 설정에서 다중선택(multiple) 컬럼 자동 감지
const [multiSelectColumns, setMultiSelectColumns] = useState<Set<string>>(new Set());
useEffect(() => {
const numScreenId = typeof screenId === "string" ? parseInt(screenId) : screenId;
if (!numScreenId) return;
const detectMultiSelect = async () => {
try {
const layout = await screenApi.getLayout(numScreenId);
if (!layout?.components) { console.log("[multiSelect] layout.components 없음", numScreenId); return; }
// 버튼 컴포넌트에서 모달 screenId 추출
const modalScreenIds = new Set<number>();
const findModalRefs = (obj: any) => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { obj.forEach(findModalRefs); return; }
const sid = obj.modalScreenId || obj.targetScreenId;
if (sid && typeof sid === "number") modalScreenIds.add(sid);
if (obj.action?.targetScreenId) modalScreenIds.add(obj.action.targetScreenId);
if (obj.action?.screenId) modalScreenIds.add(obj.action.screenId);
for (const v of Object.values(obj)) findModalRefs(v);
};
findModalRefs(layout.components);
modalScreenIds.delete(numScreenId);
console.log("[multiSelect] modalScreenIds:", [...modalScreenIds]);
if (modalScreenIds.size === 0) return;
// 모달 화면 layout에서 multiple: true 컬럼 추출
const multiCols = new Set<string>();
for (const msId of modalScreenIds) {
try {
const modalLayout = await screenApi.getLayout(msId);
if (!modalLayout?.components) continue;
const findMultiple = (obj: any) => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { obj.forEach(findMultiple); return; }
if (obj.multiple === true && obj.columnName) multiCols.add(obj.columnName);
for (const v of Object.values(obj)) findMultiple(v);
};
findMultiple(modalLayout.components);
} catch (e) { console.warn("[multiSelect] 모달 layout 조회 실패:", msId, e); }
}
console.log("[multiSelect] 다중선택 컬럼:", [...multiCols]);
if (multiCols.size > 0) setMultiSelectColumns(multiCols);
} catch (e) { console.error("[multiSelect] 감지 실패:", e); }
};
detectMultiSelect();
}, [screenId]);
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
useEffect(() => {
setAsyncColumnUniqueValues({});
@ -2672,6 +2723,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableContainerRef.current?.focus();
}, []);
const multiSelectPortalRef = useRef<boolean>(false);
// 🆕 편집 저장 (즉시 저장 또는 배치 저장)
const saveEditing = useCallback(async () => {
if (!editingCell) return;
@ -2709,7 +2762,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const primaryKeyValue = row[primaryKeyField];
if (primaryKeyValue === undefined || primaryKeyValue === null) {
console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField);
cancelEditing();
return;
}
@ -2772,6 +2824,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
pendingChanges.size,
]);
// saveEditing을 ref로 노출 (호이스팅 우회)
const saveEditingRef = useRef<() => void>();
saveEditingRef.current = saveEditing;
// 다중선택 Portal 외부 클릭 시 저장 후 닫기
useEffect(() => {
if (!editingCell) { multiSelectPortalRef.current = false; return; }
const isMulti = multiSelectColumns.has(editingCell.columnName);
multiSelectPortalRef.current = isMulti;
if (!isMulti) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-multi-select-portal]")) return;
saveEditingRef.current?.();
};
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [editingCell, multiSelectColumns]);
// 🆕 배치 저장: 모든 변경사항 한번에 저장
const saveBatchChanges = useCallback(async () => {
if (pendingChanges.size === 0) {
@ -3215,7 +3291,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
// silently ignore
}
}, [
tableStateKey,
@ -3261,7 +3337,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setHeaderFilters(filters);
}
} catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error);
// silently ignore
}
}, [tableStateKey]);
@ -3281,7 +3357,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setHeaderFilters({});
toast.success("테이블 설정이 초기화되었습니다.");
} catch (error) {
console.error("❌ 테이블 상태 초기화 실패:", error);
// silently ignore
}
}, [tableStateKey]);
@ -3993,7 +4069,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요.");
}
} catch (error) {
console.error("❌ PDF 내보내기 실패:", error);
showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
}
},
@ -4072,41 +4147,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 +4655,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 +4764,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setVisibleFilterColumns(new Set());
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setVisibleFilterColumns(new Set());
}
}, [filterSettingKey, visibleColumns]);
@ -4723,7 +4780,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 검색 값 초기화
setSearchValues({});
} catch (error) {
console.error("필터 설정 저장 실패:", error);
showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
}
}, [filterSettingKey, visibleFilterColumns]);
@ -4773,7 +4829,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 +5097,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
// silently ignore
}
}, [groupSettingKey, visibleColumns]);
@ -5055,14 +5111,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 +5336,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
savedWidths = JSON.parse(saved);
}
} catch (error) {
console.error("컬럼 너비 불러오기 실패:", error);
// silently ignore
}
}
@ -5624,6 +5672,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
editInputRef={editInputRef}
columnMeta={columnMeta}
categoryMappings={categoryMappings}
multiSelectColumns={multiSelectColumns}
/>
</div>
@ -6057,7 +6106,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 +6135,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 +6329,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
localStorage.setItem(storageKey, JSON.stringify(newWidths));
} catch (error) {
console.error("컬럼 너비 저장 실패:", error);
// silently ignore
}
}
@ -6665,6 +6714,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}),
);
// 다중선택 판별
const isMultiSelect = multiSelectColumns.has(column.columnName);
if (isMultiSelect && !cascadingConfig) {
const selectedValues = (editingValue ?? "").split(",").filter(Boolean);
const toggleValue = (val: string) => {
const next = selectedValues.includes(val)
? selectedValues.filter((v: string) => v !== val)
: [...selectedValues, val];
setEditingValue(next.join(","));
};
// Portal로 body에 렌더링 (overflow:hidden 우회)
const cellEl = document.querySelector(
`[data-row="${index}"][data-col="${colIndex}"]`
) as HTMLElement | null;
const rect = cellEl?.getBoundingClientRect();
const portalContent = rect ? ReactDOM.createPortal(
<div
data-multi-select-portal="true"
className="fixed min-w-[180px] max-h-[250px] overflow-auto rounded border-2 border-primary bg-background p-1 shadow-xl text-xs"
style={{ top: rect.top - 4, left: rect.left, transform: "translateY(-100%)", zIndex: 99999 }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") handleEditKeyDown(e as any);
if (e.key === "Enter") saveEditing();
}}
>
{selectOptions.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-muted whitespace-nowrap">
<input type="checkbox" checked={selectedValues.includes(opt.value)}
onChange={() => toggleValue(opt.value)} className="h-3 w-3" />
{opt.label}
</label>
))}
</div>,
document.body
) : null;
return <>{portalContent}<span className="text-xs text-muted-foreground">{selectedValues.length} </span></>;
}
return (
<select
ref={editInputRef as any}
@ -7011,7 +7100,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용)