jskim-node #432
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>(`
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 || "회사 전환 중 오류가 발생했습니다.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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("삭제 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export interface UserDepartmentMapping {
|
|||
export interface DepartmentFormData {
|
||||
dept_name: string; // 부서명 (필수)
|
||||
parent_dept_code?: string | null; // 상위 부서 코드
|
||||
dept_code?: string; // 채번 시스템으로 할당된 부서코드 (선택)
|
||||
}
|
||||
|
||||
// 부서 트리 노드 (UI용)
|
||||
|
|
|
|||
Loading…
Reference in New Issue