Enhance backend controllers, frontend pages, and V2 components
- Fix department, receiving, shippingOrder, shippingPlan controllers - Update admin pages (company management, disk usage) - Improve sales/logistics pages (order, shipping, outbound, receiving) - Enhance V2 components (file-upload, split-panel-layout, table-list) - Add SmartSelect common component - Update DataGrid, FullscreenDialog common components - Add gitignore rules for personal pipeline tools Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
348da95823
commit
b97ca1a1c5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("삭제에 실패했습니다."); }
|
||||
|
|
@ -373,8 +415,9 @@ export default function DepartmentPage() {
|
|||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서코드</Label>
|
||||
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
|
||||
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
|
||||
<Input value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
|
||||
className="h-9" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
|||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng";
|
|||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
|
||||
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
|
||||
{ key: "part_name", label: "품명", width: "w-[150px]", editable: true },
|
||||
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
|
||||
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||
|
|
@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
|||
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[70px]" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[110px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
|
||||
];
|
||||
|
|
@ -85,7 +88,12 @@ export default function SalesOrderPage() {
|
|||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemPageSize, setItemPageSize] = useState(20);
|
||||
const [itemTotalPages, setItemTotalPages] = useState(0);
|
||||
const [itemTotal, setItemTotal] = useState(0);
|
||||
const [itemPageInput, setItemPageInput] = useState("1");
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
|
@ -221,6 +229,23 @@ export default function SalesOrderPage() {
|
|||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
|
||||
// order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강)
|
||||
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
|
||||
let masterMap: Record<string, any> = {};
|
||||
if (orderNos.length > 0) {
|
||||
try {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: orderNos.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
for (const m of masters) {
|
||||
masterMap[m.order_no] = m;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
|
|
@ -247,19 +272,20 @@ export default function SalesOrderPage() {
|
|||
};
|
||||
const data = rows.map((row: any) => {
|
||||
const item = itemMap[row.part_code];
|
||||
const master = masterMap[row.order_no];
|
||||
const rawUnit = row.unit || item?.unit || "";
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
spec: row.spec || item?.size || "",
|
||||
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
|
||||
memo: row.memo || master?.memo || "",
|
||||
};
|
||||
});
|
||||
|
||||
setOrders(data);
|
||||
setTotalCount(res.data?.data?.total || data.length);
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -330,7 +356,6 @@ export default function SalesOrderPage() {
|
|||
setIsEditMode(true);
|
||||
setIsModalOpen(true);
|
||||
} catch (err) {
|
||||
console.error("수주 상세 조회 실패:", err);
|
||||
toast.error("수주 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -377,7 +402,6 @@ export default function SalesOrderPage() {
|
|||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
} catch (err) {
|
||||
console.error("삭제 실패:", err);
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -433,7 +457,6 @@ export default function SalesOrderPage() {
|
|||
setIsModalOpen(false);
|
||||
fetchOrders();
|
||||
} catch (err: any) {
|
||||
console.error("저장 실패:", err);
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -441,7 +464,9 @@ export default function SalesOrderPage() {
|
|||
};
|
||||
|
||||
// 품목 검색 (리피터에서 추가)
|
||||
const searchItems = async () => {
|
||||
const searchItems = async (page?: number, size?: number) => {
|
||||
const p = page ?? itemPage;
|
||||
const s = size ?? itemPageSize;
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
|
|
@ -449,18 +474,45 @@ export default function SalesOrderPage() {
|
|||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
page: p, size: s,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
const resData = res.data?.data;
|
||||
setItemSearchResults(resData?.data || resData?.rows || []);
|
||||
setItemTotal(resData?.total || 0);
|
||||
setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s));
|
||||
} catch { /* skip */ } finally {
|
||||
setItemSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemPageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > itemTotalPages) return;
|
||||
setItemPage(newPage);
|
||||
setItemPageInput(String(newPage));
|
||||
searchItems(newPage);
|
||||
};
|
||||
|
||||
const commitItemPageInput = () => {
|
||||
const parsed = parseInt(itemPageInput, 10);
|
||||
if (isNaN(parsed) || itemPageInput.trim() === "") {
|
||||
setItemPageInput(String(itemPage));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1));
|
||||
if (clamped !== itemPage) handleItemPageChange(clamped);
|
||||
setItemPageInput(String(clamped));
|
||||
};
|
||||
|
||||
const triggerNewSearch = () => {
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
searchItems(1);
|
||||
};
|
||||
|
||||
const addSelectedItemsToDetail = async () => {
|
||||
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
|
||||
const selected = Array.from(itemSelectedMap.values());
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
// 단가방식에 따라 단가 조회
|
||||
|
|
@ -492,7 +544,7 @@ export default function SalesOrderPage() {
|
|||
if (price) customerPriceMap[m.item_id] = String(price);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("거래처별 단가 조회 실패:", err);
|
||||
// 단가 조회 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,15 +568,17 @@ export default function SalesOrderPage() {
|
|||
material: getCategoryLabel("item_material", item.material) || item.material || "",
|
||||
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
|
||||
qty: "",
|
||||
standard_price: item.standard_price || "",
|
||||
unit_price: unitPrice,
|
||||
amount: "",
|
||||
currency_code: item.currency_code || "",
|
||||
due_date: "",
|
||||
};
|
||||
});
|
||||
|
||||
setDetailRows((prev) => [...prev, ...newRows]);
|
||||
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
|
||||
setItemCheckedIds(new Set());
|
||||
setItemSelectedMap(new Map());
|
||||
setItemSelectOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -655,30 +709,15 @@ export default function SalesOrderPage() {
|
|||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매 유형</Label>
|
||||
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["sell_mode"] || []} value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">입력방식</Label>
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["input_mode"] || []} value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">단가방식</Label>
|
||||
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["price_mode"] || []} value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -687,39 +726,23 @@ export default function SalesOrderPage() {
|
|||
<div className="grid grid-cols-4 gap-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처</Label>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["partner_id"] || []} value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">담당자</Label>
|
||||
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["manager_id"] || []} value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품처</Label>
|
||||
{deliveryOptions.length > 0 ? (
|
||||
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
|
||||
<SmartSelect options={deliveryOptions} value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
|
||||
// 선택한 납품처의 주소를 자동 입력
|
||||
const found = deliveryOptions.find((o) => o.code === v);
|
||||
if (found) {
|
||||
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
|
||||
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}} placeholder="납품처 선택" />
|
||||
) : (
|
||||
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
|
||||
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
|
||||
|
|
@ -738,21 +761,11 @@ export default function SalesOrderPage() {
|
|||
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">인코텀즈</Label>
|
||||
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["incoterms"] || []} value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">결제조건</Label>
|
||||
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SmartSelect options={categoryOptions["payment_term"] || []} value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
|
|
@ -781,28 +794,30 @@ export default function SalesOrderPage() {
|
|||
<div className="border rounded-lg">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
|
||||
<span className="text-sm font-semibold">수주 품목</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemPage(1); setItemPageInput("1"); setItemSearchKeyword(""); setItemSelectOpen(true); searchItems(1); }}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[300px]">
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
<TableHead className="w-[120px]">품번</TableHead>
|
||||
<TableHead className="min-w-[120px]">품명</TableHead>
|
||||
<TableHead className="w-[120px]">품명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[110px]">수량</TableHead>
|
||||
<TableHead className="w-[120px]">단가</TableHead>
|
||||
<TableHead className="w-[110px]">금액</TableHead>
|
||||
<TableHead className="w-[200px]">납기일</TableHead>
|
||||
<TableHead className="w-[100px]">기준단가</TableHead>
|
||||
<TableHead className="w-[90px]">수량</TableHead>
|
||||
<TableHead className="w-[100px]">단가</TableHead>
|
||||
<TableHead className="w-[100px]">금액</TableHead>
|
||||
<TableHead className="w-[70px]">통화</TableHead>
|
||||
<TableHead className="w-[160px]">납기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={11} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||
) : detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
<TableCell>
|
||||
|
|
@ -814,6 +829,7 @@ export default function SalesOrderPage() {
|
|||
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
||||
<TableCell className="text-xs">{row.spec}</TableCell>
|
||||
<TableCell className="text-xs">{row.unit}</TableCell>
|
||||
<TableCell className="text-sm text-right text-muted-foreground">{row.standard_price ? Number(row.standard_price).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
|
||||
className="h-8 text-sm text-right" />
|
||||
|
|
@ -823,6 +839,10 @@ export default function SalesOrderPage() {
|
|||
className="h-8 text-sm text-right" />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
<Input value={row.currency_code || ""} onChange={(e) => updateDetailRow(idx, "currency_code", e.target.value)}
|
||||
className="h-8 text-sm" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
|
||||
</TableCell>
|
||||
|
|
@ -851,22 +871,26 @@ export default function SalesOrderPage() {
|
|||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<div className="overflow-auto max-h-[320px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox"
|
||||
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
|
||||
checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
|
||||
else setItemCheckedIds(new Set());
|
||||
setItemSelectedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i));
|
||||
else itemSearchResults.forEach((i) => next.delete(i.id));
|
||||
return next;
|
||||
});
|
||||
}} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px]">품목코드</TableHead>
|
||||
|
|
@ -880,14 +904,14 @@ export default function SalesOrderPage() {
|
|||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemSelectedMap.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemSelectedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item);
|
||||
return next;
|
||||
})}>
|
||||
<TableCell className="text-center">
|
||||
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
|
||||
<input type="checkbox" checked={itemSelectedMap.has(item.id)} readOnly />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
||||
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
|
||||
|
|
@ -899,13 +923,53 @@ export default function SalesOrderPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between w-full border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">표시:</span>
|
||||
<input type="number" min={1} max={200} value={itemPageSize}
|
||||
onChange={(e) => {
|
||||
const v = Math.min(200, Math.max(1, Number(e.target.value) || 20));
|
||||
setItemPageSize(v);
|
||||
setItemPage(1);
|
||||
setItemPageInput("1");
|
||||
searchItems(1, v);
|
||||
}}
|
||||
className="h-7 w-14 rounded-md border px-1 text-center text-xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleItemPageChange(1)} disabled={itemPage === 1 || itemSearchLoading}>
|
||||
<ChevronsLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleItemPageChange(itemPage - 1)} disabled={itemPage === 1 || itemSearchLoading}>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<input type="text" inputMode="numeric" value={itemPageInput}
|
||||
onChange={(e) => setItemPageInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }}
|
||||
onBlur={commitItemPageInput}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="h-7 w-10 rounded-md border px-1 text-center text-xs" />
|
||||
<span className="text-xs text-muted-foreground">/ {itemTotalPages || 1}</span>
|
||||
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleItemPageChange(itemPage + 1)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleItemPageChange(itemTotalPages)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
|
||||
<ChevronsRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">총 {itemTotal}건</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
<span className="text-sm text-muted-foreground">{itemSelectedMap.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}>취소</Button>
|
||||
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}개 추가
|
||||
<Button variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemSelectOpen(false); }}>취소</Button>
|
||||
<Button onClick={addSelectedItemsToDetail} disabled={itemSelectedMap.size === 0}>
|
||||
<Plus className="w-4 h-4 mr-1" /> {itemSelectedMap.size}개 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()); // 펼쳐진 항목들
|
||||
|
||||
// 🆕 페이징 상태
|
||||
|
|
@ -1124,6 +1126,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return <SplitPanelCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// code 타입: code_value → code_name 변환
|
||||
if (colInputType === "code") {
|
||||
const codeCategory = columnCodeCategories[columnName];
|
||||
if (codeCategory && value) {
|
||||
try {
|
||||
const syncResult = codeCache.getCodeSync(codeCategory);
|
||||
if (syncResult && Array.isArray(syncResult)) {
|
||||
const foundCode = syncResult.find(
|
||||
(item: any) => String(item.code_value).toUpperCase() === String(value).toUpperCase(),
|
||||
);
|
||||
if (foundCode) {
|
||||
return foundCode.code_name;
|
||||
}
|
||||
} else {
|
||||
// 캐시 미스: 비동기 로딩 트리거
|
||||
codeCache.getCodeAsync(codeCategory).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 날짜 포맷 적용
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
|
|
@ -1156,20 +1181,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
if (mapping && mapping[strValue]) {
|
||||
const categoryData = mapping[strValue];
|
||||
const displayLabel = categoryData.label || strValue;
|
||||
const displayColor = categoryData.color || "#64748b";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
return categoryData.label || strValue;
|
||||
}
|
||||
|
||||
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
|
||||
|
|
@ -1178,19 +1190,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const m = categoryMappings[key];
|
||||
if (m && m[strValue]) {
|
||||
const categoryData = m[strValue];
|
||||
const displayLabel = categoryData.label || strValue;
|
||||
const displayColor = categoryData.color || "#64748b";
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
return categoryData.label || strValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1216,7 +1216,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||
[formatDateValue, formatNumberValue, columnInputTypes, columnCodeCategories],
|
||||
);
|
||||
|
||||
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||
|
|
@ -2218,6 +2218,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
loadLeftColumnLabels();
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||
|
||||
// 왼쪽 테이블 inputTypes + codeCategory 로드
|
||||
useEffect(() => {
|
||||
const loadLeftColumnInputTypes = async () => {
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTableName || isDesignMode) return;
|
||||
|
||||
try {
|
||||
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
|
||||
const inputTypes: Record<string, string> = {};
|
||||
const codeCategories: Record<string, string> = {};
|
||||
columnsResponse.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (colName) {
|
||||
inputTypes[colName] = col.inputType || "text";
|
||||
if (col.codeCategory) {
|
||||
codeCategories[colName] = col.codeCategory;
|
||||
}
|
||||
}
|
||||
});
|
||||
setColumnInputTypes((prev) => ({ ...prev, ...inputTypes }));
|
||||
setColumnCodeCategories((prev) => ({ ...prev, ...codeCategories }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
loadLeftColumnInputTypes();
|
||||
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||
|
||||
// codeCategory 프리로딩 (캐시 미스 방지)
|
||||
useEffect(() => {
|
||||
const categories = Object.values(columnCodeCategories).filter(Boolean);
|
||||
if (categories.length > 0) {
|
||||
codeCache.preloadCodes([...new Set(categories)]).catch(() => {});
|
||||
}
|
||||
}, [columnCodeCategories]);
|
||||
|
||||
// 우측 테이블 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadRightTableColumns = async () => {
|
||||
|
|
@ -2247,20 +2284,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
});
|
||||
|
||||
const inputTypes: Record<string, string> = {};
|
||||
const codeCategories: Record<string, string> = {};
|
||||
for (const tbl of tablesToLoad) {
|
||||
try {
|
||||
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||
inputTypesResponse.forEach((col: any) => {
|
||||
const tblColumnsResponse = await tableTypeApi.getColumns(tbl);
|
||||
tblColumnsResponse.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (colName) {
|
||||
inputTypes[colName] = col.inputType || "text";
|
||||
if (col.codeCategory) {
|
||||
codeCategories[colName] = col.codeCategory;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setColumnInputTypes(inputTypes);
|
||||
setColumnInputTypes((prev) => ({ ...prev, ...inputTypes }));
|
||||
setColumnCodeCategories((prev) => ({ ...prev, ...codeCategories }));
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
|
|
@ -2304,12 +2346,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
const flattenCategories = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
if (item.children && item.children.length > 0) {
|
||||
flattenCategories(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategories(response.data.data);
|
||||
|
||||
// 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장
|
||||
const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`;
|
||||
|
|
@ -2391,19 +2439,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
const flattenCategories = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
if (item.children && item.children.length > 0) {
|
||||
flattenCategories(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategories(response.data.data);
|
||||
|
||||
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
|
||||
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
|
||||
mappings[mappingKey] = valueMap;
|
||||
|
||||
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
|
||||
// 기존 매핑이 있으면 병합, 없으면 새로 생성
|
||||
// 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
|
||||
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("파일 다운로드 오류:", err);
|
||||
// silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -708,7 +708,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
|
||||
setColumnVisibility(parsed);
|
||||
} catch (error) {
|
||||
console.error("저장된 컬럼 설정 불러오기 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1157,7 +1157,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 총 아이템 수 업데이트
|
||||
setTotalItems(newData.length);
|
||||
} catch (error) {
|
||||
console.error("데이터 수신 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
|
@ -1434,7 +1433,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 순서 파싱 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
|
||||
|
|
@ -1522,7 +1521,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setColumnLabels(labels);
|
||||
setColumnMeta(meta);
|
||||
} catch (error) {
|
||||
console.error("컬럼 라벨 가져오기 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
|
|
@ -1557,7 +1556,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
|
||||
setTableLabel(label);
|
||||
} catch (error) {
|
||||
console.error("테이블 라벨 가져오기 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
|
|
@ -2036,13 +2035,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
||||
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
||||
|
||||
// console.log("✅ [TableList] API 응답 받음");
|
||||
// console.log(` - dataLength: ${response.data?.length || 0}`);
|
||||
// console.log(` - total: ${response.total}`);
|
||||
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
||||
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
||||
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
||||
|
||||
setData(response.data || []);
|
||||
setTotalPages(response.totalPages || 0);
|
||||
setTotalItems(response.total || 0);
|
||||
|
|
@ -2074,7 +2066,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("데이터 가져오기 실패:", err);
|
||||
setData([]);
|
||||
setTotalPages(0);
|
||||
setTotalItems(0);
|
||||
|
|
@ -2266,8 +2257,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2709,7 +2698,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const primaryKeyValue = row[primaryKeyField];
|
||||
|
||||
if (primaryKeyValue === undefined || primaryKeyValue === null) {
|
||||
console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField);
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
|
|
@ -3215,7 +3203,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 저장 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [
|
||||
tableStateKey,
|
||||
|
|
@ -3261,7 +3249,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setHeaderFilters(filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 복원 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [tableStateKey]);
|
||||
|
||||
|
|
@ -3281,7 +3269,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setHeaderFilters({});
|
||||
toast.success("테이블 설정이 초기화되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 초기화 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [tableStateKey]);
|
||||
|
||||
|
|
@ -3993,7 +3981,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ PDF 내보내기 실패:", error);
|
||||
showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
},
|
||||
|
|
@ -4072,41 +4059,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const lastColumnOrderRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
||||
// hasCallback: !!onSelectedRowsChange,
|
||||
// visibleColumnsLength: visibleColumns.length,
|
||||
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
||||
// });
|
||||
|
||||
if (!onSelectedRowsChange) {
|
||||
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleColumns.length === 0) {
|
||||
// console.warn("⚠️ visibleColumns가 비어있습니다!");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
||||
|
||||
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
||||
|
||||
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
||||
const columnOrderString = currentColumnOrder.join(",");
|
||||
// console.log("🔍 [컬럼 순서] 비교:", {
|
||||
// current: columnOrderString,
|
||||
// last: lastColumnOrderRef.current,
|
||||
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
||||
// });
|
||||
|
||||
if (columnOrderString === lastColumnOrderRef.current) {
|
||||
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
lastColumnOrderRef.current = columnOrderString;
|
||||
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
||||
|
||||
// 선택된 행 데이터 가져오기
|
||||
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||
|
|
@ -4597,7 +4567,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return convertedValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
|
||||
// silently ignore
|
||||
}
|
||||
// 변환 실패 시 원본 코드 값 반환
|
||||
return String(value);
|
||||
|
|
@ -4706,7 +4676,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setVisibleFilterColumns(new Set());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필터 설정 불러오기 실패:", error);
|
||||
setVisibleFilterColumns(new Set());
|
||||
}
|
||||
}, [filterSettingKey, visibleColumns]);
|
||||
|
|
@ -4723,7 +4692,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 검색 값 초기화
|
||||
setSearchValues({});
|
||||
} catch (error) {
|
||||
console.error("필터 설정 저장 실패:", error);
|
||||
showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
||||
}
|
||||
}, [filterSettingKey, visibleFilterColumns]);
|
||||
|
|
@ -4773,7 +4741,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 저장 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [groupSettingKey, groupByColumns]);
|
||||
|
||||
|
|
@ -5041,7 +5009,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setGroupByColumns(savedGroups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 불러오기 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}, [groupSettingKey, visibleColumns]);
|
||||
|
||||
|
|
@ -5055,14 +5023,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
||||
// isDesignMode,
|
||||
// tableName: tableConfig.selectedTable,
|
||||
// currentPage,
|
||||
// sortColumn,
|
||||
// sortDirection,
|
||||
// });
|
||||
|
||||
if (!isDesignMode && tableConfig.selectedTable) {
|
||||
fetchTableDataDebounced();
|
||||
}
|
||||
|
|
@ -5288,7 +5248,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
savedWidths = JSON.parse(saved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 너비 불러오기 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6057,7 +6017,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
isColumnDropTarget && "border-l-primary border-l-4",
|
||||
)}
|
||||
style={{
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : "left",
|
||||
width:
|
||||
column.columnName === "__checkbox__"
|
||||
? "48px"
|
||||
|
|
@ -6086,7 +6046,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: "4px" }}>
|
||||
{isColumnDragEnabled && (
|
||||
<GripVertical className="absolute left-0.5 top-1/2 h-3 w-3 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-40" />
|
||||
)}
|
||||
|
|
@ -6280,7 +6240,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newWidths));
|
||||
} catch (error) {
|
||||
console.error("컬럼 너비 저장 실패:", error);
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7011,7 +6971,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
toast.success("행이 삭제되었습니다");
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
console.error("삭제 오류:", error);
|
||||
toast.error("삭제 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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