Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
ad5c7f643c
|
|
@ -74,6 +74,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
|||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -240,6 +241,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
|||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { Client } from "pg";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { query, queryOne, getPool } from "../database/db";
|
||||
import config from "../config/environment";
|
||||
import { AdminService } from "../services/adminService";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
|
|
@ -3406,3 +3406,395 @@ export async function copyMenu(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
* 사원 + 부서 통합 관리 API
|
||||
* ============================================================
|
||||
*
|
||||
* 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다.
|
||||
*
|
||||
* ## 핵심 기능
|
||||
* 1. user_info 테이블에 사원 개인정보 저장
|
||||
* 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장
|
||||
* 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환
|
||||
* 4. 트랜잭션으로 데이터 정합성 보장
|
||||
*
|
||||
* ## 요청 데이터 구조
|
||||
* ```json
|
||||
* {
|
||||
* "userInfo": {
|
||||
* "user_id": "string (필수)",
|
||||
* "user_name": "string (필수)",
|
||||
* "email": "string",
|
||||
* "cell_phone": "string",
|
||||
* "sabun": "string",
|
||||
* ...
|
||||
* },
|
||||
* "mainDept": {
|
||||
* "dept_code": "string (필수)",
|
||||
* "dept_name": "string",
|
||||
* "position_name": "string"
|
||||
* },
|
||||
* "subDepts": [
|
||||
* {
|
||||
* "dept_code": "string (필수)",
|
||||
* "dept_name": "string",
|
||||
* "position_name": "string"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// 사원 + 부서 저장 요청 타입
|
||||
interface UserWithDeptRequest {
|
||||
userInfo: {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng?: string;
|
||||
user_password?: string;
|
||||
email?: string;
|
||||
tel?: string;
|
||||
cell_phone?: string;
|
||||
sabun?: string;
|
||||
user_type?: string;
|
||||
user_type_name?: string;
|
||||
status?: string;
|
||||
locale?: string;
|
||||
// 메인 부서 정보 (user_info에도 저장)
|
||||
dept_code?: string;
|
||||
dept_name?: string;
|
||||
position_code?: string;
|
||||
position_name?: string;
|
||||
};
|
||||
mainDept?: {
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
};
|
||||
subDepts?: Array<{
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
}>;
|
||||
isUpdate?: boolean; // 수정 모드 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/users/with-dept
|
||||
* 사원 + 부서 통합 저장 API
|
||||
*/
|
||||
export const saveUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const client = await getPool().connect();
|
||||
|
||||
try {
|
||||
const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const currentUserId = req.user?.userId;
|
||||
|
||||
logger.info("사원+부서 통합 저장 요청", {
|
||||
userId: userInfo?.user_id,
|
||||
mainDept: mainDept?.dept_code,
|
||||
subDeptsCount: subDepts.length,
|
||||
isUpdate,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수값 검증
|
||||
if (!userInfo?.user_id || !userInfo?.user_name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자 ID와 이름은 필수입니다.",
|
||||
error: { code: "REQUIRED_FIELD_MISSING" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 트랜잭션 시작
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 기존 사용자 확인
|
||||
const existingUser = await client.query(
|
||||
"SELECT user_id FROM user_info WHERE user_id = $1",
|
||||
[userInfo.user_id]
|
||||
);
|
||||
const isExistingUser = existingUser.rows.length > 0;
|
||||
|
||||
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
|
||||
let encryptedPassword = null;
|
||||
if (userInfo.user_password) {
|
||||
encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password);
|
||||
}
|
||||
|
||||
// 3. user_info 저장 (UPSERT)
|
||||
// mainDept가 있으면 user_info에도 메인 부서 정보 저장
|
||||
const deptCode = mainDept?.dept_code || userInfo.dept_code || null;
|
||||
const deptName = mainDept?.dept_name || userInfo.dept_name || null;
|
||||
const positionName = mainDept?.position_name || userInfo.position_name || null;
|
||||
|
||||
if (isExistingUser) {
|
||||
// 기존 사용자 수정
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 동적으로 업데이트할 필드 구성
|
||||
const fieldsToUpdate: Record<string, any> = {
|
||||
user_name: userInfo.user_name,
|
||||
user_name_eng: userInfo.user_name_eng,
|
||||
email: userInfo.email,
|
||||
tel: userInfo.tel,
|
||||
cell_phone: userInfo.cell_phone,
|
||||
sabun: userInfo.sabun,
|
||||
user_type: userInfo.user_type,
|
||||
user_type_name: userInfo.user_type_name,
|
||||
status: userInfo.status || "active",
|
||||
locale: userInfo.locale,
|
||||
dept_code: deptCode,
|
||||
dept_name: deptName,
|
||||
position_code: userInfo.position_code,
|
||||
position_name: positionName,
|
||||
company_code: companyCode !== "*" ? companyCode : undefined,
|
||||
};
|
||||
|
||||
// 비밀번호가 제공된 경우에만 업데이트
|
||||
if (encryptedPassword) {
|
||||
fieldsToUpdate.user_password = encryptedPassword;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(fieldsToUpdate)) {
|
||||
if (value !== undefined) {
|
||||
updateFields.push(`${key} = $${paramIndex}`);
|
||||
updateValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFields.length > 0) {
|
||||
updateValues.push(userInfo.user_id);
|
||||
await client.query(
|
||||
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
|
||||
updateValues
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 새 사용자 등록
|
||||
await client.query(
|
||||
`INSERT INTO user_info (
|
||||
user_id, user_name, user_name_eng, user_password,
|
||||
email, tel, cell_phone, sabun,
|
||||
user_type, user_type_name, status, locale,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
company_code, regdate
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
|
||||
[
|
||||
userInfo.user_id,
|
||||
userInfo.user_name,
|
||||
userInfo.user_name_eng || null,
|
||||
encryptedPassword || null,
|
||||
userInfo.email || null,
|
||||
userInfo.tel || null,
|
||||
userInfo.cell_phone || null,
|
||||
userInfo.sabun || null,
|
||||
userInfo.user_type || null,
|
||||
userInfo.user_type_name || null,
|
||||
userInfo.status || "active",
|
||||
userInfo.locale || null,
|
||||
deptCode,
|
||||
deptName,
|
||||
userInfo.position_code || null,
|
||||
positionName,
|
||||
companyCode !== "*" ? companyCode : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. user_dept 처리
|
||||
if (mainDept?.dept_code || subDepts.length > 0) {
|
||||
// 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용)
|
||||
const existingDepts = await client.query(
|
||||
"SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1",
|
||||
[userInfo.user_id]
|
||||
);
|
||||
const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true);
|
||||
|
||||
// 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환
|
||||
if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) {
|
||||
logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", {
|
||||
userId: userInfo.user_id,
|
||||
oldMain: existingMainDept.dept_code,
|
||||
newMain: mainDept.dept_code,
|
||||
});
|
||||
|
||||
await client.query(
|
||||
"UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2",
|
||||
[userInfo.user_id, existingMainDept.dept_code]
|
||||
);
|
||||
}
|
||||
|
||||
// 4-3. 기존 겸직 부서 삭제 (메인 제외)
|
||||
// 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제
|
||||
await client.query(
|
||||
"DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false",
|
||||
[userInfo.user_id]
|
||||
);
|
||||
|
||||
// 4-4. 메인 부서 저장 (UPSERT)
|
||||
if (mainDept?.dept_code) {
|
||||
await client.query(
|
||||
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
||||
is_primary = true,
|
||||
dept_name = $3,
|
||||
user_name = $4,
|
||||
position_name = $5,
|
||||
company_code = $6,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
userInfo.user_id,
|
||||
mainDept.dept_code,
|
||||
mainDept.dept_name || null,
|
||||
userInfo.user_name,
|
||||
mainDept.position_name || null,
|
||||
companyCode !== "*" ? companyCode : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 4-5. 겸직 부서 저장
|
||||
for (const subDept of subDepts) {
|
||||
if (!subDept.dept_code) continue;
|
||||
|
||||
// 메인 부서와 같은 부서는 겸직으로 추가하지 않음
|
||||
if (mainDept?.dept_code === subDept.dept_code) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
||||
VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
||||
is_primary = false,
|
||||
dept_name = $3,
|
||||
user_name = $4,
|
||||
position_name = $5,
|
||||
company_code = $6,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
userInfo.user_id,
|
||||
subDept.dept_code,
|
||||
subDept.dept_name || null,
|
||||
userInfo.user_name,
|
||||
subDept.position_name || null,
|
||||
companyCode !== "*" ? companyCode : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 트랜잭션 커밋
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("사원+부서 통합 저장 완료", {
|
||||
userId: userInfo.user_id,
|
||||
isUpdate: isExistingUser,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
||||
data: {
|
||||
userId: userInfo.user_id,
|
||||
isUpdate: isExistingUser,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 트랜잭션 롤백
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body });
|
||||
|
||||
// 중복 키 에러 처리
|
||||
if (error.code === "23505") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이미 존재하는 사용자 ID입니다.",
|
||||
error: { code: "DUPLICATE_USER_ID" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사원 저장 중 오류가 발생했습니다.",
|
||||
error: { code: "SAVE_ERROR", details: error.message },
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:userId/with-dept
|
||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||
*/
|
||||
export const getUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
logger.info("사원+부서 조회 요청", { userId, companyCode });
|
||||
|
||||
// 1. user_info 조회
|
||||
let userQuery = "SELECT * FROM user_info WHERE user_id = $1";
|
||||
const userParams: any[] = [userId];
|
||||
|
||||
// 최고 관리자가 아니면 회사 필터링
|
||||
if (companyCode !== "*") {
|
||||
userQuery += " AND company_code = $2";
|
||||
userParams.push(companyCode);
|
||||
}
|
||||
|
||||
const userResult = await query<any>(userQuery, userParams);
|
||||
|
||||
if (userResult.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
error: { code: "USER_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfo = userResult[0];
|
||||
|
||||
// 2. user_dept 조회 (메인 + 겸직)
|
||||
let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC";
|
||||
const deptResult = await query<any>(deptQuery, [userId]);
|
||||
|
||||
const mainDept = deptResult.find((d: any) => d.is_primary === true);
|
||||
const subDepts = deptResult.filter((d: any) => d.is_primary === false);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userInfo,
|
||||
mainDept: mainDept || null,
|
||||
subDepts,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사원 조회 중 오류가 발생했습니다.",
|
||||
error: { code: "QUERY_ERROR", details: error.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 세금계산서 컨트롤러
|
||||
* 세금계산서 API 엔드포인트 처리
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { TaxInvoiceService } from "../services/taxInvoiceService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TaxInvoiceController {
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
* GET /api/tax-invoice
|
||||
*/
|
||||
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
page = "1",
|
||||
pageSize = "20",
|
||||
invoice_type,
|
||||
invoice_status,
|
||||
start_date,
|
||||
end_date,
|
||||
search,
|
||||
buyer_name,
|
||||
cost_type,
|
||||
} = req.query;
|
||||
|
||||
const result = await TaxInvoiceService.getList(companyCode, {
|
||||
page: parseInt(page as string, 10),
|
||||
pageSize: parseInt(pageSize as string, 10),
|
||||
invoice_type: invoice_type as "sales" | "purchase" | undefined,
|
||||
invoice_status: invoice_status as string | undefined,
|
||||
start_date: start_date as string | undefined,
|
||||
end_date: end_date as string | undefined,
|
||||
search: search as string | undefined,
|
||||
buyer_name: buyer_name as string | undefined,
|
||||
cost_type: cost_type as any,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.pageSize),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
* GET /api/tax-invoice/:id
|
||||
*/
|
||||
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.getById(id, companyCode);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
* POST /api/tax-invoice
|
||||
*/
|
||||
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.invoice_type) {
|
||||
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (!data.invoice_date) {
|
||||
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (data.supply_amount === undefined || data.supply_amount === null) {
|
||||
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await TaxInvoiceService.create(data, companyCode, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
* PUT /api/tax-invoice/:id
|
||||
*/
|
||||
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 수정 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
* DELETE /api/tax-invoice/:id
|
||||
*/
|
||||
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.delete(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "세금계산서가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 삭제 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
* POST /api/tax-invoice/:id/issue
|
||||
*/
|
||||
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.issue(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 발행되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 발행 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
* POST /api/tax-invoice/:id/cancel
|
||||
*/
|
||||
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 취소 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/tax-invoice/stats/monthly
|
||||
*/
|
||||
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = req.query;
|
||||
const now = new Date();
|
||||
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
|
||||
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
|
||||
|
||||
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
period: { year: targetYear, month: targetMonth },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("월별 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
* GET /api/tax-invoice/stats/cost-type
|
||||
*/
|
||||
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = req.query;
|
||||
const targetYear = year ? parseInt(year as string, 10) : undefined;
|
||||
const targetMonth = month ? parseInt(month as string, 10) : undefined;
|
||||
|
||||
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
period: { year: targetYear, month: targetMonth },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("비용 유형별 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,16 @@ export const errorHandler = (
|
|||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||
if ((err as any).code) {
|
||||
const pgError = err as any;
|
||||
// 원본 에러 메시지 로깅 (디버깅용)
|
||||
console.error("🔴 PostgreSQL Error:", {
|
||||
code: pgError.code,
|
||||
message: pgError.message,
|
||||
detail: pgError.detail,
|
||||
hint: pgError.hint,
|
||||
table: pgError.table,
|
||||
column: pgError.column,
|
||||
constraint: pgError.constraint,
|
||||
});
|
||||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
if (pgError.code === "23505") {
|
||||
// unique_violation
|
||||
|
|
@ -42,7 +52,7 @@ export const errorHandler = (
|
|||
// 기타 무결성 제약 조건 위반
|
||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||
} else {
|
||||
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
||||
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
getDepartmentList, // 부서 목록 조회
|
||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||
saveUser, // 사용자 등록/수정
|
||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
|
|
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
|||
router.get("/users", getUserList);
|
||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||
router.put("/profile", updateProfile); // 프로필 수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 세금계산서 라우터
|
||||
* /api/tax-invoice 경로 처리
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 목록 조회
|
||||
router.get("/", TaxInvoiceController.getList);
|
||||
|
||||
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
||||
|
||||
// 비용 유형별 통계
|
||||
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
||||
|
||||
// 상세 조회
|
||||
router.get("/:id", TaxInvoiceController.getById);
|
||||
|
||||
// 생성
|
||||
router.post("/", TaxInvoiceController.create);
|
||||
|
||||
// 수정
|
||||
router.put("/:id", TaxInvoiceController.update);
|
||||
|
||||
// 삭제
|
||||
router.delete("/:id", TaxInvoiceController.delete);
|
||||
|
||||
// 발행
|
||||
router.post("/:id/issue", TaxInvoiceController.issue);
|
||||
|
||||
// 취소
|
||||
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -1160,7 +1160,15 @@ export class DynamicFormService {
|
|||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||||
console.log("📊 SQL 파라미터:", [id]);
|
||||
|
||||
const result = await query<any>(deleteQuery, [id]);
|
||||
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
|
||||
const result = await transaction(async (client) => {
|
||||
// 이력 트리거에서 사용할 사용자 정보 설정
|
||||
if (userId) {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
}
|
||||
const res = await client.query(deleteQuery, [id]);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,784 @@
|
|||
/**
|
||||
* 세금계산서 서비스
|
||||
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||
*/
|
||||
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 비용 유형 타입
|
||||
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||
|
||||
// 세금계산서 타입 정의
|
||||
export interface TaxInvoice {
|
||||
id: string;
|
||||
company_code: string;
|
||||
invoice_number: string;
|
||||
invoice_type: "sales" | "purchase"; // 매출/매입
|
||||
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||
|
||||
// 공급자 정보
|
||||
supplier_business_no: string;
|
||||
supplier_name: string;
|
||||
supplier_ceo_name: string;
|
||||
supplier_address: string;
|
||||
supplier_business_type: string;
|
||||
supplier_business_item: string;
|
||||
|
||||
// 공급받는자 정보
|
||||
buyer_business_no: string;
|
||||
buyer_name: string;
|
||||
buyer_ceo_name: string;
|
||||
buyer_address: string;
|
||||
buyer_email: string;
|
||||
|
||||
// 금액 정보
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
|
||||
// 날짜 정보
|
||||
invoice_date: string;
|
||||
issue_date: string | null;
|
||||
|
||||
// 기타
|
||||
remarks: string;
|
||||
order_id: string | null;
|
||||
customer_id: string | null;
|
||||
|
||||
// 첨부파일 (JSON 배열로 저장)
|
||||
attachments: TaxInvoiceAttachment[] | null;
|
||||
|
||||
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
|
||||
cost_type: CostType | null;
|
||||
|
||||
created_date: string;
|
||||
updated_date: string;
|
||||
writer: string;
|
||||
}
|
||||
|
||||
// 첨부파일 타입
|
||||
export interface TaxInvoiceAttachment {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
uploaded_at: string;
|
||||
uploaded_by: string;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceItem {
|
||||
id: string;
|
||||
tax_invoice_id: string;
|
||||
company_code: string;
|
||||
item_seq: number;
|
||||
item_date: string;
|
||||
item_name: string;
|
||||
item_spec: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export interface CreateTaxInvoiceDto {
|
||||
invoice_type: "sales" | "purchase";
|
||||
supplier_business_no?: string;
|
||||
supplier_name?: string;
|
||||
supplier_ceo_name?: string;
|
||||
supplier_address?: string;
|
||||
supplier_business_type?: string;
|
||||
supplier_business_item?: string;
|
||||
buyer_business_no?: string;
|
||||
buyer_name?: string;
|
||||
buyer_ceo_name?: string;
|
||||
buyer_address?: string;
|
||||
buyer_email?: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
remarks?: string;
|
||||
order_id?: string;
|
||||
customer_id?: string;
|
||||
items?: CreateTaxInvoiceItemDto[];
|
||||
attachments?: TaxInvoiceAttachment[]; // 첨부파일
|
||||
cost_type?: CostType; // 비용 유형
|
||||
}
|
||||
|
||||
export interface CreateTaxInvoiceItemDto {
|
||||
item_date?: string;
|
||||
item_name: string;
|
||||
item_spec?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
invoice_type?: "sales" | "purchase";
|
||||
invoice_status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
search?: string;
|
||||
buyer_name?: string;
|
||||
cost_type?: CostType; // 비용 유형 필터
|
||||
}
|
||||
|
||||
export class TaxInvoiceService {
|
||||
/**
|
||||
* 세금계산서 번호 채번
|
||||
* 형식: YYYYMM-NNNNN (예: 202512-00001)
|
||||
*/
|
||||
static async generateInvoiceNumber(companyCode: string): Promise<string> {
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const prefix = `${yearMonth}-`;
|
||||
|
||||
// 해당 월의 마지막 번호 조회
|
||||
const result = await query<{ max_num: string }>(
|
||||
`SELECT invoice_number as max_num
|
||||
FROM tax_invoice
|
||||
WHERE company_code = $1
|
||||
AND invoice_number LIKE $2
|
||||
ORDER BY invoice_number DESC
|
||||
LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let nextNum = 1;
|
||||
if (result.length > 0 && result[0].max_num) {
|
||||
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
|
||||
nextNum = lastNum + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${String(nextNum).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
*/
|
||||
static async getList(
|
||||
companyCode: string,
|
||||
params: TaxInvoiceListParams
|
||||
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
invoice_type,
|
||||
invoice_status,
|
||||
start_date,
|
||||
end_date,
|
||||
search,
|
||||
buyer_name,
|
||||
cost_type,
|
||||
} = params;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (invoice_type) {
|
||||
conditions.push(`invoice_type = $${paramIndex}`);
|
||||
values.push(invoice_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (invoice_status) {
|
||||
conditions.push(`invoice_status = $${paramIndex}`);
|
||||
values.push(invoice_status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
conditions.push(`invoice_date >= $${paramIndex}`);
|
||||
values.push(start_date);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
conditions.push(`invoice_date <= $${paramIndex}`);
|
||||
values.push(end_date);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
|
||||
);
|
||||
values.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (buyer_name) {
|
||||
conditions.push(`buyer_name ILIKE $${paramIndex}`);
|
||||
values.push(`%${buyer_name}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (cost_type) {
|
||||
conditions.push(`cost_type = $${paramIndex}`);
|
||||
values.push(cost_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 전체 개수 조회
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
|
||||
values
|
||||
);
|
||||
const total = parseInt(countResult[0]?.count || "0", 10);
|
||||
|
||||
// 데이터 조회
|
||||
values.push(pageSize, offset);
|
||||
const data = await query<TaxInvoice>(
|
||||
`SELECT * FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
ORDER BY created_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
values
|
||||
);
|
||||
|
||||
return { data, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회 (품목 포함)
|
||||
*/
|
||||
static async getById(
|
||||
id: string,
|
||||
companyCode: string
|
||||
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
|
||||
const invoiceResult = await query<TaxInvoice>(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (invoiceResult.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = await query<TaxInvoiceItem>(
|
||||
`SELECT * FROM tax_invoice_item
|
||||
WHERE tax_invoice_id = $1 AND company_code = $2
|
||||
ORDER BY item_seq`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return { invoice: invoiceResult[0], items };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
*/
|
||||
static async create(
|
||||
data: CreateTaxInvoiceDto,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TaxInvoice> {
|
||||
return await transaction(async (client) => {
|
||||
// 세금계산서 번호 채번
|
||||
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
|
||||
|
||||
// 세금계산서 생성
|
||||
const invoiceResult = await client.query(
|
||||
`INSERT INTO tax_invoice (
|
||||
company_code, invoice_number, invoice_type, invoice_status,
|
||||
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
|
||||
supplier_business_type, supplier_business_item,
|
||||
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
|
||||
supply_amount, tax_amount, total_amount, invoice_date,
|
||||
remarks, order_id, customer_id, attachments, cost_type, writer
|
||||
) VALUES (
|
||||
$1, $2, $3, 'draft',
|
||||
$4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18,
|
||||
$19, $20, $21, $22, $23, $24
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
invoiceNumber,
|
||||
data.invoice_type,
|
||||
data.supplier_business_no || null,
|
||||
data.supplier_name || null,
|
||||
data.supplier_ceo_name || null,
|
||||
data.supplier_address || null,
|
||||
data.supplier_business_type || null,
|
||||
data.supplier_business_item || null,
|
||||
data.buyer_business_no || null,
|
||||
data.buyer_name || null,
|
||||
data.buyer_ceo_name || null,
|
||||
data.buyer_address || null,
|
||||
data.buyer_email || null,
|
||||
data.supply_amount,
|
||||
data.tax_amount,
|
||||
data.total_amount,
|
||||
data.invoice_date,
|
||||
data.remarks || null,
|
||||
data.order_id || null,
|
||||
data.customer_id || null,
|
||||
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||
data.cost_type || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
|
||||
// 품목 생성
|
||||
if (data.items && data.items.length > 0) {
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
await client.query(
|
||||
`INSERT INTO tax_invoice_item (
|
||||
tax_invoice_id, company_code, item_seq,
|
||||
item_date, item_name, item_spec, quantity, unit_price,
|
||||
supply_amount, tax_amount, remarks
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
invoice.id,
|
||||
companyCode,
|
||||
i + 1,
|
||||
item.item_date || null,
|
||||
item.item_name,
|
||||
item.item_spec || null,
|
||||
item.quantity,
|
||||
item.unit_price,
|
||||
item.supply_amount,
|
||||
item.tax_amount,
|
||||
item.remarks || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("세금계산서 생성 완료", {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
*/
|
||||
static async update(
|
||||
id: string,
|
||||
data: Partial<CreateTaxInvoiceDto>,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TaxInvoice | null> {
|
||||
return await transaction(async (client) => {
|
||||
// 기존 세금계산서 확인
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 발행된 세금계산서는 수정 불가
|
||||
if (existing.rows[0].invoice_status !== "draft") {
|
||||
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 세금계산서 수정
|
||||
const updateResult = await client.query(
|
||||
`UPDATE tax_invoice SET
|
||||
supplier_business_no = COALESCE($3, supplier_business_no),
|
||||
supplier_name = COALESCE($4, supplier_name),
|
||||
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
|
||||
supplier_address = COALESCE($6, supplier_address),
|
||||
supplier_business_type = COALESCE($7, supplier_business_type),
|
||||
supplier_business_item = COALESCE($8, supplier_business_item),
|
||||
buyer_business_no = COALESCE($9, buyer_business_no),
|
||||
buyer_name = COALESCE($10, buyer_name),
|
||||
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
|
||||
buyer_address = COALESCE($12, buyer_address),
|
||||
buyer_email = COALESCE($13, buyer_email),
|
||||
supply_amount = COALESCE($14, supply_amount),
|
||||
tax_amount = COALESCE($15, tax_amount),
|
||||
total_amount = COALESCE($16, total_amount),
|
||||
invoice_date = COALESCE($17, invoice_date),
|
||||
remarks = COALESCE($18, remarks),
|
||||
attachments = $19,
|
||||
cost_type = COALESCE($20, cost_type),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
companyCode,
|
||||
data.supplier_business_no,
|
||||
data.supplier_name,
|
||||
data.supplier_ceo_name,
|
||||
data.supplier_address,
|
||||
data.supplier_business_type,
|
||||
data.supplier_business_item,
|
||||
data.buyer_business_no,
|
||||
data.buyer_name,
|
||||
data.buyer_ceo_name,
|
||||
data.buyer_address,
|
||||
data.buyer_email,
|
||||
data.supply_amount,
|
||||
data.tax_amount,
|
||||
data.total_amount,
|
||||
data.invoice_date,
|
||||
data.remarks,
|
||||
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||
data.cost_type,
|
||||
]
|
||||
);
|
||||
|
||||
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||
if (data.items) {
|
||||
await client.query(
|
||||
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
await client.query(
|
||||
`INSERT INTO tax_invoice_item (
|
||||
tax_invoice_id, company_code, item_seq,
|
||||
item_date, item_name, item_spec, quantity, unit_price,
|
||||
supply_amount, tax_amount, remarks
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
id,
|
||||
companyCode,
|
||||
i + 1,
|
||||
item.item_date || null,
|
||||
item.item_name,
|
||||
item.item_spec || null,
|
||||
item.quantity,
|
||||
item.unit_price,
|
||||
item.supply_amount,
|
||||
item.tax_amount,
|
||||
item.remarks || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return updateResult.rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
|
||||
return await transaction(async (client) => {
|
||||
// 기존 세금계산서 확인
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 발행된 세금계산서는 삭제 불가
|
||||
if (existing.rows[0].invoice_status !== "draft") {
|
||||
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 품목 삭제
|
||||
await client.query(
|
||||
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
// 세금계산서 삭제
|
||||
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행 (상태 변경)
|
||||
*/
|
||||
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
|
||||
const result = await query<TaxInvoice>(
|
||||
`UPDATE tax_invoice SET
|
||||
invoice_status = 'issued',
|
||||
issue_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
|
||||
RETURNING *`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
static async cancel(
|
||||
id: string,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
reason?: string
|
||||
): Promise<TaxInvoice | null> {
|
||||
const result = await query<TaxInvoice>(
|
||||
`UPDATE tax_invoice SET
|
||||
invoice_status = 'cancelled',
|
||||
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
|
||||
RETURNING *`,
|
||||
[id, companyCode, reason || null]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
static async getMonthlyStats(
|
||||
companyCode: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<{
|
||||
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
}> {
|
||||
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
|
||||
|
||||
const result = await query<{
|
||||
invoice_type: string;
|
||||
count: string;
|
||||
supply_amount: string;
|
||||
tax_amount: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
invoice_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE company_code = $1
|
||||
AND invoice_date >= $2
|
||||
AND invoice_date <= $3
|
||||
AND invoice_status != 'cancelled'
|
||||
GROUP BY invoice_type`,
|
||||
[companyCode, startDate, endDate]
|
||||
);
|
||||
|
||||
const stats = {
|
||||
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||
};
|
||||
|
||||
for (const row of result) {
|
||||
const type = row.invoice_type as "sales" | "purchase";
|
||||
stats[type] = {
|
||||
count: parseInt(row.count, 10),
|
||||
supply_amount: parseFloat(row.supply_amount),
|
||||
tax_amount: parseFloat(row.tax_amount),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
*/
|
||||
static async getCostTypeStats(
|
||||
companyCode: string,
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<{
|
||||
by_cost_type: Array<{
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
by_month: Array<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
summary: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
purchase_amount: number;
|
||||
installation_amount: number;
|
||||
repair_amount: number;
|
||||
maintenance_amount: number;
|
||||
disposal_amount: number;
|
||||
other_amount: number;
|
||||
};
|
||||
}> {
|
||||
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 연도/월 필터
|
||||
if (year && month) {
|
||||
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
|
||||
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
|
||||
values.push(startDate, endDate);
|
||||
paramIndex += 2;
|
||||
} else if (year) {
|
||||
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
|
||||
values.push(year);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 비용 유형별 집계
|
||||
const byCostType = await query<{
|
||||
cost_type: CostType | null;
|
||||
count: string;
|
||||
supply_amount: string;
|
||||
tax_amount: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
cost_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
GROUP BY cost_type
|
||||
ORDER BY total_amount DESC`,
|
||||
values
|
||||
);
|
||||
|
||||
// 월별 비용 유형 집계
|
||||
const byMonth = await query<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
|
||||
cost_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
|
||||
ORDER BY year_month DESC, cost_type`,
|
||||
values
|
||||
);
|
||||
|
||||
// 전체 요약
|
||||
const summaryResult = await query<{
|
||||
total_count: string;
|
||||
total_amount: string;
|
||||
purchase_amount: string;
|
||||
installation_amount: string;
|
||||
repair_amount: string;
|
||||
maintenance_amount: string;
|
||||
disposal_amount: string;
|
||||
other_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}`,
|
||||
values
|
||||
);
|
||||
|
||||
const summary = summaryResult[0] || {
|
||||
total_count: "0",
|
||||
total_amount: "0",
|
||||
purchase_amount: "0",
|
||||
installation_amount: "0",
|
||||
repair_amount: "0",
|
||||
maintenance_amount: "0",
|
||||
disposal_amount: "0",
|
||||
other_amount: "0",
|
||||
};
|
||||
|
||||
return {
|
||||
by_cost_type: byCostType.map((row) => ({
|
||||
cost_type: row.cost_type,
|
||||
count: parseInt(row.count, 10),
|
||||
supply_amount: parseFloat(row.supply_amount),
|
||||
tax_amount: parseFloat(row.tax_amount),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
})),
|
||||
by_month: byMonth.map((row) => ({
|
||||
year_month: row.year_month,
|
||||
cost_type: row.cost_type,
|
||||
count: parseInt(row.count, 10),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
})),
|
||||
summary: {
|
||||
total_count: parseInt(summary.total_count, 10),
|
||||
total_amount: parseFloat(summary.total_amount),
|
||||
purchase_amount: parseFloat(summary.purchase_amount),
|
||||
installation_amount: parseFloat(summary.installation_amount),
|
||||
repair_amount: parseFloat(summary.repair_amount),
|
||||
maintenance_amount: parseFloat(summary.maintenance_amount),
|
||||
disposal_amount: parseFloat(summary.disposal_amount),
|
||||
other_amount: parseFloat(summary.other_amount),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6,6 +6,11 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
|
|
@ -137,7 +142,9 @@ export function TableHistoryModal({
|
|||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
|
||||
const date = new Date(dateString);
|
||||
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 대시보드
|
||||
* 구매/설치/수리/유지보수/폐기 등 비용 정산 현황
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Package,
|
||||
Wrench,
|
||||
Settings,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice";
|
||||
|
||||
// 비용 유형별 아이콘
|
||||
const costTypeIcons: Record<CostType, React.ReactNode> = {
|
||||
purchase: <Package className="h-4 w-4" />,
|
||||
installation: <Settings className="h-4 w-4" />,
|
||||
repair: <Wrench className="h-4 w-4" />,
|
||||
maintenance: <Settings className="h-4 w-4" />,
|
||||
disposal: <Trash2 className="h-4 w-4" />,
|
||||
other: <DollarSign className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// 비용 유형별 색상
|
||||
const costTypeColors: Record<CostType, string> = {
|
||||
purchase: "bg-blue-500",
|
||||
installation: "bg-green-500",
|
||||
repair: "bg-orange-500",
|
||||
maintenance: "bg-purple-500",
|
||||
disposal: "bg-red-500",
|
||||
other: "bg-gray-500",
|
||||
};
|
||||
|
||||
export function CostTypeStats() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<CostTypeStatsResponse["data"] | null>(null);
|
||||
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(undefined);
|
||||
|
||||
// 연도 옵션 생성 (최근 5년)
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||
|
||||
// 월 옵션 생성
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
// 데이터 로드
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCostTypeStats(selectedYear, selectedMonth);
|
||||
if (response.success) {
|
||||
setStats(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("통계 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedYear, selectedMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
if (amount >= 100000000) {
|
||||
return `${(amount / 100000000).toFixed(1)}억`;
|
||||
}
|
||||
if (amount >= 10000) {
|
||||
return `${(amount / 10000).toFixed(0)}만`;
|
||||
}
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 전체 금액 대비 비율 계산
|
||||
const getPercentage = (amount: number) => {
|
||||
if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0;
|
||||
return (amount / stats.summary.total_amount) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">비용 정산 현황</h2>
|
||||
<p className="text-muted-foreground text-sm">구매/설치/수리/유지보수/폐기 비용 통계</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(selectedYear)}
|
||||
onValueChange={(v) => setSelectedYear(parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}년
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={selectedMonth ? String(selectedMonth) : "all"}
|
||||
onValueChange={(v) => setSelectedMonth(v === "all" ? undefined : parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{monthOptions.map((month) => (
|
||||
<SelectItem key={month} value={String(month)}>
|
||||
{month}월
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={loadStats} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 비용</CardTitle>
|
||||
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount(stats?.summary.total_amount || 0)}원
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{stats?.summary.total_count || 0}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">구매 비용</CardTitle>
|
||||
<Package className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount(stats?.summary.purchase_amount || 0)}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage(stats?.summary.purchase_amount || 0)}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">수리/유지보수</CardTitle>
|
||||
<Wrench className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">설치/폐기</CardTitle>
|
||||
<Settings className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 비용 유형별 상세 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>비용 유형별 상세</CardTitle>
|
||||
<CardDescription>각 비용 유형별 금액 및 비율</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{stats?.by_cost_type && stats.by_cost_type.length > 0 ? (
|
||||
stats.by_cost_type.map((item) => {
|
||||
const costType = item.cost_type as CostType;
|
||||
const percentage = getPercentage(item.total_amount);
|
||||
return (
|
||||
<div key={costType || "none"} className="flex items-center gap-4">
|
||||
<div className="flex w-[120px] items-center gap-2">
|
||||
{costType && costTypeIcons[costType]}
|
||||
<span className="text-sm font-medium">
|
||||
{costType ? costTypeLabels[costType] : "미분류"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${costType ? costTypeColors[costType] : "bg-gray-400"}`}
|
||||
style={{ width: `${Math.max(percentage, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-[50px] text-right text-sm text-muted-foreground">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[120px] text-right">
|
||||
<div className="font-mono text-sm font-semibold">
|
||||
{formatAmount(item.total_amount)}원
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.count}건</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 월별 추이 */}
|
||||
{!selectedMonth && stats?.by_month && stats.by_month.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>월별 비용 추이</CardTitle>
|
||||
<CardDescription>{selectedYear}년 월별 비용 현황</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 월별 그룹핑 */}
|
||||
{Array.from(new Set(stats.by_month.map((item) => item.year_month)))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 6)
|
||||
.map((yearMonth) => {
|
||||
const monthData = stats.by_month.filter((item) => item.year_month === yearMonth);
|
||||
const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0);
|
||||
const [year, month] = yearMonth.split("-");
|
||||
|
||||
return (
|
||||
<div key={yearMonth} className="flex items-center gap-4 py-2 border-b last:border-0">
|
||||
<div className="w-[80px] text-sm font-medium">
|
||||
{month}월
|
||||
</div>
|
||||
<div className="flex-1 flex gap-1">
|
||||
{monthData.map((item) => {
|
||||
const costType = item.cost_type as CostType;
|
||||
const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={costType || "none"}
|
||||
className={`h-6 ${costType ? costTypeColors[costType] : "bg-gray-400"} rounded`}
|
||||
style={{ width: `${Math.max(width, 5)}%` }}
|
||||
title={`${costType ? costTypeLabels[costType] : "미분류"}: ${formatAmount(item.total_amount)}원`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-[100px] text-right font-mono text-sm">
|
||||
{formatAmount(monthTotal)}원
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="mt-4 flex flex-wrap gap-3 pt-4 border-t">
|
||||
{Object.entries(costTypeLabels).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${costTypeColors[key as CostType]}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 보기 컴포넌트
|
||||
* PDF 출력 및 첨부파일 다운로드 기능 포함
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import {
|
||||
Printer,
|
||||
Download,
|
||||
FileText,
|
||||
Image,
|
||||
File,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getTaxInvoiceById,
|
||||
TaxInvoice,
|
||||
TaxInvoiceItem,
|
||||
TaxInvoiceAttachment,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface TaxInvoiceDetailProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "임시저장",
|
||||
issued: "발행완료",
|
||||
sent: "전송완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-gray-100 text-gray-800",
|
||||
issued: "bg-green-100 text-green-800",
|
||||
sent: "bg-blue-100 text-blue-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
|
||||
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
|
||||
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open && invoiceId) {
|
||||
loadData();
|
||||
}
|
||||
}, [open, invoiceId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaxInvoiceById(invoiceId);
|
||||
if (response.success) {
|
||||
setInvoice(response.data.invoice);
|
||||
setItems(response.data.items);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
|
||||
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
|
||||
// objid가 숫자형이면 API를 통해 미리보기
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
// apiClient의 baseURL 사용
|
||||
const baseURL = apiClient.defaults.baseURL || "";
|
||||
return `${baseURL}/files/preview/${attachment.id}`;
|
||||
}
|
||||
return attachment.file_path;
|
||||
};
|
||||
|
||||
// 공통 인쇄용 HTML 생성 함수
|
||||
const generatePrintHtml = (autoPrint: boolean = false) => {
|
||||
if (!invoice) return "";
|
||||
|
||||
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
|
||||
const itemsHtml = items.map((item, index) => `
|
||||
<tr>
|
||||
<td style="text-align:center">${index + 1}</td>
|
||||
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
|
||||
<td>${item.item_name}</td>
|
||||
<td>${item.item_spec || "-"}</td>
|
||||
<td style="text-align:right">${item.quantity}</td>
|
||||
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>세금계산서_${invoice.invoice_number}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
||||
.header .invoice-number { font-size: 14px; color: #666; }
|
||||
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
|
||||
.status-draft { background: #f3f4f6; color: #374151; }
|
||||
.status-issued { background: #d1fae5; color: #065f46; }
|
||||
.status-sent { background: #dbeafe; color: #1e40af; }
|
||||
.status-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
|
||||
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||
.party-label { width: 80px; color: #666; }
|
||||
.party-value { flex: 1; }
|
||||
.items-section { margin-bottom: 30px; }
|
||||
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
.total-section { display: flex; justify-content: flex-end; }
|
||||
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
|
||||
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
|
||||
.total-row.grand .value { color: #1d4ed8; }
|
||||
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
|
||||
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
|
||||
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
|
||||
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.attachments ul { list-style: none; font-size: 12px; }
|
||||
.attachments li { padding: 4px 0; }
|
||||
@media print {
|
||||
body { padding: 15px; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>세금계산서 (${invoiceTypeText})</h1>
|
||||
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
|
||||
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<h3>공급자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">업태/종목</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
|
||||
</div>
|
||||
<div class="party">
|
||||
<h3>공급받는자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">이메일</span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<h3>품목 내역</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">No</th>
|
||||
<th style="width:80px">일자</th>
|
||||
<th>품목명</th>
|
||||
<th style="width:70px">규격</th>
|
||||
<th style="width:50px">수량</th>
|
||||
<th style="width:80px">단가</th>
|
||||
<th style="width:90px">공급가액</th>
|
||||
<th style="width:70px">세액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="total-section">
|
||||
<div class="total-box">
|
||||
<div class="total-row"><span>공급가액</span><span>${formatAmount(invoice.supply_amount)}원</span></div>
|
||||
<div class="total-row"><span>세액</span><span>${formatAmount(invoice.tax_amount)}원</span></div>
|
||||
<div class="total-row grand"><span>합계금액</span><span class="value">${formatAmount(invoice.total_amount)}원</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
|
||||
|
||||
${invoice.attachments && invoice.attachments.length > 0 ? `
|
||||
<div class="attachments">
|
||||
<h3>첨부파일 (${invoice.attachments.length}개)</h3>
|
||||
<ul>
|
||||
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="footer">
|
||||
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
|
||||
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
if (!invoice) return;
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
// PDF 다운로드 (인쇄 다이얼로그 사용)
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoice) return;
|
||||
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
|
||||
} catch (error: any) {
|
||||
console.error("PDF 생성 오류:", error);
|
||||
toast.error("PDF 생성 실패", { description: error.message });
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 아이콘
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
// 파일 다운로드 (인증 토큰 포함)
|
||||
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
|
||||
try {
|
||||
// objid가 숫자형이면 API를 통해 다운로드
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
const response = await apiClient.get(`/files/download/${attachment.id}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
// Blob으로 다운로드
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = attachment.file_name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// 직접 경로로 다운로드
|
||||
window.open(attachment.file_path, "_blank");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("파일 다운로드 실패", { description: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
|
||||
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
>
|
||||
{pdfLoading ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)]">
|
||||
<div className="p-6" ref={printRef}>
|
||||
<div className="invoice-container">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="mb-2 text-2xl font-bold">
|
||||
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
계산서번호: {invoice.invoice_number}
|
||||
</p>
|
||||
<Badge className={statusColors[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 공급자 / 공급받는자 정보 */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-6">
|
||||
{/* 공급자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.supplier_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.supplier_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.supplier_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">업태/종목</span>
|
||||
<span>
|
||||
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.supplier_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급받는자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급받는자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.buyer_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.buyer_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.buyer_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">이메일</span>
|
||||
<span>{invoice.buyer_email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.buyer_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold">품목 내역</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
|
||||
<TableCell>{item.item_name}</TableCell>
|
||||
<TableCell>{item.item_spec || "-"}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.unit_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.tax_amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
|
||||
품목 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.supply_amount)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.tax_amount)}원</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>합계금액</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(invoice.total_amount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
{invoice.remarks && (
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-2 font-semibold">비고</h3>
|
||||
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
|
||||
{invoice.remarks}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜 정보 */}
|
||||
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
|
||||
<span>작성일: {formatDate(invoice.invoice_date)}</span>
|
||||
{invoice.issue_date && <span>발행일: {formatDate(invoice.issue_date)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{invoice.attachments && invoice.attachments.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<Separator className="mb-4" />
|
||||
<h3 className="mb-3 font-semibold">첨부파일 ({invoice.attachments.length}개)</h3>
|
||||
|
||||
{/* 이미지 미리보기 */}
|
||||
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{invoice.attachments
|
||||
.filter((f) => f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
|
||||
>
|
||||
<img
|
||||
src={getFilePreviewUrl(file)}
|
||||
alt={file.file_name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="w-full p-2">
|
||||
<p className="truncate text-xs text-white">{file.file_name}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기타 파일 목록 */}
|
||||
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
|
||||
<div className="space-y-2">
|
||||
{invoice.attachments
|
||||
.filter((f) => !f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<span className="text-sm">{file.file_name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 작성/수정 폼
|
||||
* 파일 첨부 기능 포함
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
Image,
|
||||
File,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createTaxInvoice,
|
||||
updateTaxInvoice,
|
||||
getTaxInvoiceById,
|
||||
TaxInvoice,
|
||||
TaxInvoiceAttachment,
|
||||
CreateTaxInvoiceDto,
|
||||
CreateTaxInvoiceItemDto,
|
||||
CostType,
|
||||
costTypeLabels,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface TaxInvoiceFormProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
invoice?: TaxInvoice | null;
|
||||
}
|
||||
|
||||
// 품목 초기값
|
||||
const emptyItem: CreateTaxInvoiceItemDto = {
|
||||
item_date: format(new Date(), "yyyy-MM-dd"),
|
||||
item_name: "",
|
||||
item_spec: "",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
remarks: "",
|
||||
};
|
||||
|
||||
export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) {
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<CreateTaxInvoiceDto>({
|
||||
invoice_type: "sales",
|
||||
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
total_amount: 0,
|
||||
items: [{ ...emptyItem }],
|
||||
});
|
||||
|
||||
// 첨부파일 상태
|
||||
const [attachments, setAttachments] = useState<TaxInvoiceAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
// 수정 모드일 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (invoice) {
|
||||
loadInvoiceData(invoice.id);
|
||||
} else {
|
||||
// 새 세금계산서
|
||||
setFormData({
|
||||
invoice_type: "sales",
|
||||
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
total_amount: 0,
|
||||
items: [{ ...emptyItem }],
|
||||
});
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
// 세금계산서 데이터 로드
|
||||
const loadInvoiceData = async (id: string) => {
|
||||
try {
|
||||
const response = await getTaxInvoiceById(id);
|
||||
if (response.success) {
|
||||
const { invoice: inv, items } = response.data;
|
||||
setFormData({
|
||||
invoice_type: inv.invoice_type,
|
||||
invoice_date: inv.invoice_date?.split("T")[0] || "",
|
||||
supplier_business_no: inv.supplier_business_no,
|
||||
supplier_name: inv.supplier_name,
|
||||
supplier_ceo_name: inv.supplier_ceo_name,
|
||||
supplier_address: inv.supplier_address,
|
||||
supplier_business_type: inv.supplier_business_type,
|
||||
supplier_business_item: inv.supplier_business_item,
|
||||
buyer_business_no: inv.buyer_business_no,
|
||||
buyer_name: inv.buyer_name,
|
||||
buyer_ceo_name: inv.buyer_ceo_name,
|
||||
buyer_address: inv.buyer_address,
|
||||
buyer_email: inv.buyer_email,
|
||||
supply_amount: inv.supply_amount,
|
||||
tax_amount: inv.tax_amount,
|
||||
total_amount: inv.total_amount,
|
||||
remarks: inv.remarks,
|
||||
cost_type: inv.cost_type || undefined,
|
||||
items:
|
||||
items.length > 0
|
||||
? items.map((item) => ({
|
||||
item_date: item.item_date?.split("T")[0] || "",
|
||||
item_name: item.item_name,
|
||||
item_spec: item.item_spec,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
supply_amount: item.supply_amount,
|
||||
tax_amount: item.tax_amount,
|
||||
remarks: item.remarks,
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
setAttachments(inv.attachments || []);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 변경
|
||||
const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 품목 변경
|
||||
const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const items = [...(prev.items || [])];
|
||||
items[index] = { ...items[index], [field]: value };
|
||||
|
||||
// 공급가액 자동 계산
|
||||
if (field === "quantity" || field === "unit_price") {
|
||||
const qty = field === "quantity" ? value : items[index].quantity;
|
||||
const price = field === "unit_price" ? value : items[index].unit_price;
|
||||
items[index].supply_amount = qty * price;
|
||||
items[index].tax_amount = Math.round(items[index].supply_amount * 0.1);
|
||||
}
|
||||
|
||||
// 총액 재계산
|
||||
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
items,
|
||||
supply_amount: totalSupply,
|
||||
tax_amount: totalTax,
|
||||
total_amount: totalSupply + totalTax,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
const handleAddItem = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: [...(prev.items || []), { ...emptyItem }],
|
||||
}));
|
||||
};
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = (index: number) => {
|
||||
setFormData((prev) => {
|
||||
const items = (prev.items || []).filter((_, i) => i !== index);
|
||||
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
items: items.length > 0 ? items : [{ ...emptyItem }],
|
||||
supply_amount: totalSupply,
|
||||
tax_amount: totalTax,
|
||||
total_amount: totalSupply + totalTax,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
|
||||
formDataUpload.append("category", "tax-invoice");
|
||||
|
||||
const response = await apiClient.post("/files/upload", formDataUpload, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.files?.length > 0) {
|
||||
const uploadedFile = response.data.files[0];
|
||||
const newAttachment: TaxInvoiceAttachment = {
|
||||
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file_name: uploadedFile.realFileName || file.name,
|
||||
file_path: uploadedFile.filePath,
|
||||
file_size: uploadedFile.fileSize || file.size,
|
||||
file_type: file.type,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
uploaded_by: "",
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
toast.success(`'${file.name}' 업로드 완료`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("파일 업로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// input 초기화
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 첨부파일 삭제
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
// 파일 아이콘
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
// 파일 크기 포맷
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.invoice_date) {
|
||||
toast.error("작성일자를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
attachments,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (invoice) {
|
||||
response = await updateTaxInvoice(invoice.id, dataToSave);
|
||||
} else {
|
||||
response = await createTaxInvoice(dataToSave);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message || "저장되었습니다.");
|
||||
onSave();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("저장 실패", { description: error.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[900px] overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{invoice ? "세금계산서 수정" : "세금계산서 작성"}</DialogTitle>
|
||||
<DialogDescription>세금계산서 정보를 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-180px)]">
|
||||
<div className="space-y-4 p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">기본정보</TabsTrigger>
|
||||
<TabsTrigger value="supplier">공급자</TabsTrigger>
|
||||
<TabsTrigger value="buyer">공급받는자</TabsTrigger>
|
||||
<TabsTrigger value="attachments">
|
||||
첨부파일
|
||||
{attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본정보 탭 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">유형 *</Label>
|
||||
<Select
|
||||
value={formData.invoice_type}
|
||||
onValueChange={(v) => handleChange("invoice_type", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">비용 유형</Label>
|
||||
<Select
|
||||
value={formData.cost_type || "none"}
|
||||
onValueChange={(v) => handleChange("cost_type", v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{Object.entries(costTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작성일자 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.invoice_date}
|
||||
onChange={(e) => handleChange("invoice_date", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input
|
||||
value={formData.remarks || ""}
|
||||
onChange={(e) => handleChange("remarks", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">품목 내역</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(formData.items || []).map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.item_date || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_date", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_name || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_name", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="품목명"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_spec || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_spec", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.unit_price || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(
|
||||
index,
|
||||
"unit_price",
|
||||
parseFloat(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.supply_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.tax_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
disabled={(formData.items?.length || 0) <= 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(formData.supply_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2 text-lg font-bold">
|
||||
<span>합계</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(formData.total_amount || 0)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급자 탭 */}
|
||||
<TabsContent value="supplier" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_no || ""}
|
||||
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.supplier_name || ""}
|
||||
onChange={(e) => handleChange("supplier_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.supplier_ceo_name || ""}
|
||||
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">업태</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_type || ""}
|
||||
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="업태"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종목</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_item || ""}
|
||||
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="종목"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.supplier_address || ""}
|
||||
onChange={(e) => handleChange("supplier_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급받는자 탭 */}
|
||||
<TabsContent value="buyer" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.buyer_business_no || ""}
|
||||
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.buyer_name || ""}
|
||||
onChange={(e) => handleChange("buyer_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.buyer_ceo_name || ""}
|
||||
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.buyer_email || ""}
|
||||
onChange={(e) => handleChange("buyer_email", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.buyer_address || ""}
|
||||
onChange={(e) => handleChange("buyer_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 첨부파일 탭 */}
|
||||
<TabsContent value="attachments" className="space-y-4">
|
||||
{/* 업로드 영역 */}
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex cursor-pointer flex-col items-center gap-2"
|
||||
>
|
||||
<Upload className="text-muted-foreground h-8 w-8" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
PDF, 이미지, 문서 파일 (최대 10MB)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 목록 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">첨부된 파일 ({attachments.length}개)</Label>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.file_name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveAttachment(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
첨부된 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트
|
||||
* 세금계산서 목록 조회, 검색, 필터링 기능
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
FileText,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Paperclip,
|
||||
Image,
|
||||
File,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getTaxInvoiceList,
|
||||
deleteTaxInvoice,
|
||||
issueTaxInvoice,
|
||||
cancelTaxInvoice,
|
||||
TaxInvoice,
|
||||
TaxInvoiceListParams,
|
||||
CostType,
|
||||
costTypeLabels,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||
|
||||
// 상태 뱃지 색상
|
||||
const statusBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
issued: "default",
|
||||
sent: "secondary",
|
||||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
// 상태 라벨
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "임시저장",
|
||||
issued: "발행완료",
|
||||
sent: "전송완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
// 유형 라벨
|
||||
const typeLabels: Record<string, string> = {
|
||||
sales: "매출",
|
||||
purchase: "매입",
|
||||
};
|
||||
|
||||
// 컬럼 정의
|
||||
interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: "text" | "select";
|
||||
filterOptions?: { value: string; label: string }[];
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
const columns: ColumnDef[] = [
|
||||
{ key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" },
|
||||
{ key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "70px" },
|
||||
{ key: "cost_type", label: "비용유형", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: Object.entries(costTypeLabels).map(([value, label]) => ({ value, label })), width: "90px" },
|
||||
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: [
|
||||
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
|
||||
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
|
||||
], width: "90px" },
|
||||
{ key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" },
|
||||
{ key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" },
|
||||
{ key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" },
|
||||
{ key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" },
|
||||
{ key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" },
|
||||
{ key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" },
|
||||
];
|
||||
|
||||
export function TaxInvoiceList() {
|
||||
// 상태
|
||||
const [invoices, setInvoices] = useState<TaxInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState<TaxInvoiceListParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 정렬 상태
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
|
||||
// 컬럼별 필터 상태
|
||||
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
|
||||
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
|
||||
|
||||
// 모달 상태
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<TaxInvoice | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// 확인 다이얼로그 상태
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
type: "delete" | "issue" | "cancel";
|
||||
invoice: TaxInvoice | null;
|
||||
}>({
|
||||
open: false,
|
||||
type: "delete",
|
||||
invoice: null,
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 컬럼 필터를 API 파라미터에 추가
|
||||
const apiFilters: TaxInvoiceListParams = {
|
||||
...filters,
|
||||
invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined,
|
||||
invoice_status: columnFilters.invoice_status,
|
||||
cost_type: columnFilters.cost_type as CostType | undefined,
|
||||
search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined,
|
||||
};
|
||||
|
||||
const response = await getTaxInvoiceList(apiFilters);
|
||||
if (response.success) {
|
||||
let data = response.data;
|
||||
|
||||
// 클라이언트 사이드 정렬 적용
|
||||
if (sortConfig) {
|
||||
data = [...data].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key as keyof TaxInvoice];
|
||||
const bVal = b[sortConfig.key as keyof TaxInvoice];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
// 숫자 비교
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
const strA = String(aVal).toLowerCase();
|
||||
const strB = String(bVal).toLowerCase();
|
||||
if (sortConfig.direction === "asc") {
|
||||
return strA.localeCompare(strB, "ko");
|
||||
}
|
||||
return strB.localeCompare(strA, "ko");
|
||||
});
|
||||
}
|
||||
|
||||
// 클라이언트 사이드 필터 적용 (날짜 필터)
|
||||
if (columnFilters.invoice_date) {
|
||||
data = data.filter((item) =>
|
||||
item.invoice_date?.includes(columnFilters.invoice_date)
|
||||
);
|
||||
}
|
||||
|
||||
setInvoices(data);
|
||||
setPagination(response.pagination);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, sortConfig, columnFilters, searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 정렬 핸들러
|
||||
const handleSort = (columnKey: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (prev?.key === columnKey) {
|
||||
// 같은 컬럼 클릭: asc -> desc -> null 순환
|
||||
if (prev.direction === "asc") return { key: columnKey, direction: "desc" };
|
||||
return null;
|
||||
}
|
||||
// 새 컬럼: asc로 시작
|
||||
return { key: columnKey, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 필터 핸들러
|
||||
const handleColumnFilter = (columnKey: string, value: string) => {
|
||||
setColumnFilters((prev) => {
|
||||
if (!value) {
|
||||
const { [columnKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [columnKey]: value };
|
||||
});
|
||||
setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const clearColumnFilter = (columnKey: string) => {
|
||||
setColumnFilters((prev) => {
|
||||
const { [columnKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setActiveFilterColumn(null);
|
||||
};
|
||||
|
||||
// 모든 필터 초기화
|
||||
const clearAllFilters = () => {
|
||||
setColumnFilters({});
|
||||
setSortConfig(null);
|
||||
setSearchText("");
|
||||
setFilters({ page: 1, pageSize: 20 });
|
||||
};
|
||||
|
||||
// 정렬 아이콘 렌더링
|
||||
const renderSortIcon = (columnKey: string) => {
|
||||
if (sortConfig?.key !== columnKey) {
|
||||
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-30" />;
|
||||
}
|
||||
return sortConfig.direction === "asc"
|
||||
? <ArrowUp className="ml-1 h-3 w-3 text-primary" />
|
||||
: <ArrowDown className="ml-1 h-3 w-3 text-primary" />;
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
setFilters((prev) => ({ ...prev, search: searchText, page: 1 }));
|
||||
};
|
||||
|
||||
// 필터 변경
|
||||
const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[key]: value === "all" ? undefined : value,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
// 새 세금계산서
|
||||
const handleNew = () => {
|
||||
setSelectedInvoice(null);
|
||||
setEditMode(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = (invoice: TaxInvoice) => {
|
||||
setSelectedInvoice(invoice);
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
// 수정
|
||||
const handleEdit = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setSelectedInvoice(invoice);
|
||||
setEditMode(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "delete", invoice });
|
||||
};
|
||||
|
||||
// 발행 확인
|
||||
const handleIssueConfirm = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "issue", invoice });
|
||||
};
|
||||
|
||||
// 취소 확인
|
||||
const handleCancelConfirm = (invoice: TaxInvoice) => {
|
||||
if (!["draft", "issued"].includes(invoice.invoice_status)) {
|
||||
toast.warning("취소할 수 없는 상태입니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "cancel", invoice });
|
||||
};
|
||||
|
||||
// 확인 다이얼로그 실행
|
||||
const handleConfirmAction = async () => {
|
||||
const { type, invoice } = confirmDialog;
|
||||
if (!invoice) return;
|
||||
|
||||
try {
|
||||
if (type === "delete") {
|
||||
const response = await deleteTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 삭제되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
} else if (type === "issue") {
|
||||
const response = await issueTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 발행되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
} else if (type === "cancel") {
|
||||
const response = await cancelTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 취소되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("작업 실패", { description: error.message });
|
||||
} finally {
|
||||
setConfirmDialog({ open: false, type: "delete", invoice: null });
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 저장 완료
|
||||
const handleFormSave = () => {
|
||||
setShowForm(false);
|
||||
setSelectedInvoice(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy-MM-dd", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">세금계산서 관리</h1>
|
||||
<Button onClick={handleNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 세금계산서
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{/* 검색 */}
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label className="text-xs">검색</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="계산서번호, 거래처명 검색"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유형 필터 */}
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs">유형</Label>
|
||||
<Select
|
||||
value={filters.invoice_type || "all"}
|
||||
onValueChange={(v) => handleFilterChange("invoice_type", v as "sales" | "purchase")}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select
|
||||
value={filters.invoice_status || "all"}
|
||||
onValueChange={(v) => handleFilterChange("invoice_status", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="draft">임시저장</SelectItem>
|
||||
<SelectItem value="issued">발행완료</SelectItem>
|
||||
<SelectItem value="sent">전송완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 */}
|
||||
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{(Object.keys(columnFilters).length > 0 || sortConfig) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="text-muted-foreground">
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 표시 */}
|
||||
{Object.keys(columnFilters).length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(columnFilters).map(([key, value]) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
let displayValue = value;
|
||||
if (column?.filterOptions) {
|
||||
displayValue = column.filterOptions.find((o) => o.value === value)?.label || value;
|
||||
}
|
||||
return (
|
||||
<Badge key={key} variant="secondary" className="gap-1 pr-1">
|
||||
{column?.label}: {displayValue}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => clearColumnFilter(key)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={`
|
||||
${column.width ? `w-[${column.width}]` : ""}
|
||||
${column.align === "center" ? "text-center" : ""}
|
||||
${column.align === "right" ? "text-right" : ""}
|
||||
`}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${column.align === "right" ? "justify-end" : column.align === "center" ? "justify-center" : ""}`}>
|
||||
{/* 컬럼 필터 (filterable인 경우) */}
|
||||
{column.filterable && (
|
||||
<Popover
|
||||
open={activeFilterColumn === column.key}
|
||||
onOpenChange={(open) => setActiveFilterColumn(open ? column.key : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 p-0 ${columnFilters[column.key] ? "text-primary" : "text-muted-foreground opacity-50 hover:opacity-100"}`}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2" align="start">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{column.label} 필터</div>
|
||||
{column.filterType === "select" ? (
|
||||
<Select
|
||||
value={columnFilters[column.key] || ""}
|
||||
onValueChange={(v) => {
|
||||
handleColumnFilter(column.key, v);
|
||||
setActiveFilterColumn(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.filterOptions?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={`${column.label} 검색...`}
|
||||
value={columnFilters[column.key] || ""}
|
||||
onChange={(e) => handleColumnFilter(column.key, e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)}
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{columnFilters[column.key] && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => clearColumnFilter(column.key)}
|
||||
>
|
||||
필터 지우기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* 컬럼 라벨 + 정렬 */}
|
||||
{column.sortable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 font-medium hover:bg-transparent"
|
||||
onClick={() => handleSort(column.key)}
|
||||
>
|
||||
{column.label}
|
||||
{renderSortIcon(column.key)}
|
||||
</Button>
|
||||
) : (
|
||||
<span>{column.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : invoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
세금계산서가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id} className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-sm">{invoice.invoice_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={invoice.invoice_type === "sales" ? "default" : "secondary"}>
|
||||
{typeLabels[invoice.invoice_type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.cost_type ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{costTypeLabels[invoice.cost_type as CostType]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{invoice.buyer_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{invoice.attachments && invoice.attachments.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{invoice.attachments.length}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.tax_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">
|
||||
{formatAmount(invoice.total_amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleView(invoice)}
|
||||
title="상세보기"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{invoice.invoice_status === "draft" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(invoice)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleIssueConfirm(invoice)}
|
||||
title="발행"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDeleteConfirm(invoice)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.invoice_status === "issued" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-orange-600"
|
||||
onClick={() => handleCancelConfirm(invoice)}
|
||||
title="취소"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}-
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.total)}건
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 작성/수정 폼 */}
|
||||
{showForm && (
|
||||
<TaxInvoiceForm
|
||||
open={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSave={handleFormSave}
|
||||
invoice={editMode ? selectedInvoice : null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 상세 */}
|
||||
{showDetail && selectedInvoice && (
|
||||
<TaxInvoiceDetail
|
||||
open={showDetail}
|
||||
onClose={() => setShowDetail(false)}
|
||||
invoiceId={selectedInvoice.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={confirmDialog.open}
|
||||
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmDialog.type === "delete" && "세금계산서 삭제"}
|
||||
{confirmDialog.type === "issue" && "세금계산서 발행"}
|
||||
{confirmDialog.type === "cancel" && "세금계산서 취소"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmDialog.type === "delete" &&
|
||||
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
|
||||
{confirmDialog.type === "issue" &&
|
||||
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
|
||||
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
|
||||
onClick={handleConfirmAction}
|
||||
>
|
||||
{confirmDialog.type === "delete" && "삭제"}
|
||||
{confirmDialog.type === "issue" && "발행"}
|
||||
{confirmDialog.type === "cancel" && "취소 처리"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { TaxInvoiceList } from "./TaxInvoiceList";
|
||||
export { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||
export { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* 세금계산서 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 비용 유형
|
||||
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||
|
||||
// 비용 유형 라벨
|
||||
export const costTypeLabels: Record<CostType, string> = {
|
||||
purchase: "구매",
|
||||
installation: "설치",
|
||||
repair: "수리",
|
||||
maintenance: "유지보수",
|
||||
disposal: "폐기",
|
||||
other: "기타",
|
||||
};
|
||||
|
||||
// 세금계산서 타입
|
||||
export interface TaxInvoice {
|
||||
id: string;
|
||||
company_code: string;
|
||||
invoice_number: string;
|
||||
invoice_type: "sales" | "purchase";
|
||||
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||
supplier_business_no: string;
|
||||
supplier_name: string;
|
||||
supplier_ceo_name: string;
|
||||
supplier_address: string;
|
||||
supplier_business_type: string;
|
||||
supplier_business_item: string;
|
||||
buyer_business_no: string;
|
||||
buyer_name: string;
|
||||
buyer_ceo_name: string;
|
||||
buyer_address: string;
|
||||
buyer_email: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
issue_date: string | null;
|
||||
remarks: string;
|
||||
order_id: string | null;
|
||||
customer_id: string | null;
|
||||
attachments: TaxInvoiceAttachment[] | null;
|
||||
cost_type: CostType | null; // 비용 유형
|
||||
created_date: string;
|
||||
updated_date: string;
|
||||
writer: string;
|
||||
}
|
||||
|
||||
// 첨부파일 타입
|
||||
export interface TaxInvoiceAttachment {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
uploaded_at: string;
|
||||
uploaded_by: string;
|
||||
}
|
||||
|
||||
// 세금계산서 품목 타입
|
||||
export interface TaxInvoiceItem {
|
||||
id: string;
|
||||
tax_invoice_id: string;
|
||||
company_code: string;
|
||||
item_seq: number;
|
||||
item_date: string;
|
||||
item_name: string;
|
||||
item_spec: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
// 생성 DTO
|
||||
export interface CreateTaxInvoiceDto {
|
||||
invoice_type: "sales" | "purchase";
|
||||
supplier_business_no?: string;
|
||||
supplier_name?: string;
|
||||
supplier_ceo_name?: string;
|
||||
supplier_address?: string;
|
||||
supplier_business_type?: string;
|
||||
supplier_business_item?: string;
|
||||
buyer_business_no?: string;
|
||||
buyer_name?: string;
|
||||
buyer_ceo_name?: string;
|
||||
buyer_address?: string;
|
||||
buyer_email?: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
remarks?: string;
|
||||
order_id?: string;
|
||||
customer_id?: string;
|
||||
items?: CreateTaxInvoiceItemDto[];
|
||||
attachments?: TaxInvoiceAttachment[];
|
||||
cost_type?: CostType; // 비용 유형
|
||||
}
|
||||
|
||||
// 품목 생성 DTO
|
||||
export interface CreateTaxInvoiceItemDto {
|
||||
item_date?: string;
|
||||
item_name: string;
|
||||
item_spec?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
// 목록 조회 파라미터
|
||||
export interface TaxInvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
invoice_type?: "sales" | "purchase";
|
||||
invoice_status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
search?: string;
|
||||
buyer_name?: string;
|
||||
cost_type?: CostType; // 비용 유형 필터
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
export interface TaxInvoiceListResponse {
|
||||
success: boolean;
|
||||
data: TaxInvoice[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 상세 응답
|
||||
export interface TaxInvoiceDetailResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
invoice: TaxInvoice;
|
||||
items: TaxInvoiceItem[];
|
||||
};
|
||||
}
|
||||
|
||||
// 월별 통계 응답
|
||||
export interface TaxInvoiceMonthlyStatsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
};
|
||||
period: { year: number; month: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
*/
|
||||
export async function getTaxInvoiceList(
|
||||
params?: TaxInvoiceListParams
|
||||
): Promise<TaxInvoiceListResponse> {
|
||||
const response = await apiClient.get("/tax-invoice", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
*/
|
||||
export async function getTaxInvoiceById(id: string): Promise<TaxInvoiceDetailResponse> {
|
||||
const response = await apiClient.get(`/tax-invoice/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
*/
|
||||
export async function createTaxInvoice(
|
||||
data: CreateTaxInvoiceDto
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post("/tax-invoice", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
*/
|
||||
export async function updateTaxInvoice(
|
||||
id: string,
|
||||
data: Partial<CreateTaxInvoiceDto>
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.put(`/tax-invoice/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
export async function deleteTaxInvoice(
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.delete(`/tax-invoice/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
*/
|
||||
export async function issueTaxInvoice(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post(`/tax-invoice/${id}/issue`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
export async function cancelTaxInvoice(
|
||||
id: string,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
export async function getTaxInvoiceMonthlyStats(
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<TaxInvoiceMonthlyStatsResponse> {
|
||||
const params: Record<string, number> = {};
|
||||
if (year) params.year = year;
|
||||
if (month) params.month = month;
|
||||
const response = await apiClient.get("/tax-invoice/stats/monthly", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 비용 유형별 통계 응답
|
||||
export interface CostTypeStatsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
by_cost_type: Array<{
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
by_month: Array<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
summary: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
purchase_amount: number;
|
||||
installation_amount: number;
|
||||
repair_amount: number;
|
||||
maintenance_amount: number;
|
||||
disposal_amount: number;
|
||||
other_amount: number;
|
||||
};
|
||||
};
|
||||
period: { year?: number; month?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
*/
|
||||
export async function getCostTypeStats(
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<CostTypeStatsResponse> {
|
||||
const params: Record<string, number> = {};
|
||||
if (year) params.year = year;
|
||||
if (month) params.month = month;
|
||||
const response = await apiClient.get("/tax-invoice/stats/cost-type", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 사원 + 부서 통합 관리 API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 사원 + 부서 정보 저장 요청 타입
|
||||
*/
|
||||
export interface SaveUserWithDeptRequest {
|
||||
userInfo: {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_name_eng?: string;
|
||||
user_password?: string;
|
||||
email?: string;
|
||||
tel?: string;
|
||||
cell_phone?: string;
|
||||
sabun?: string;
|
||||
user_type?: string;
|
||||
user_type_name?: string;
|
||||
status?: string;
|
||||
locale?: string;
|
||||
dept_code?: string;
|
||||
dept_name?: string;
|
||||
position_code?: string;
|
||||
position_name?: string;
|
||||
};
|
||||
mainDept?: {
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
};
|
||||
subDepts?: Array<{
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
}>;
|
||||
isUpdate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 + 부서 정보 응답 타입
|
||||
*/
|
||||
export interface UserWithDeptResponse {
|
||||
userInfo: Record<string, any>;
|
||||
mainDept: {
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
is_primary: boolean;
|
||||
} | null;
|
||||
subDepts: Array<{
|
||||
dept_code: string;
|
||||
dept_name?: string;
|
||||
position_name?: string;
|
||||
is_primary: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 + 부서 통합 저장
|
||||
*
|
||||
* user_info와 user_dept 테이블에 트랜잭션으로 동시 저장합니다.
|
||||
* - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환
|
||||
* - 겸직 부서는 전체 삭제 후 재입력 방식
|
||||
*
|
||||
* @param data 저장할 사원 및 부서 정보
|
||||
* @returns 저장 결과
|
||||
*/
|
||||
export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise<ApiResponse<{ userId: string; isUpdate: boolean }>> {
|
||||
try {
|
||||
console.log("사원+부서 통합 저장 API 호출:", data);
|
||||
|
||||
const response = await apiClient.post("/admin/users/with-dept", data);
|
||||
|
||||
console.log("사원+부서 통합 저장 API 응답:", response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("사원+부서 통합 저장 API 오류:", error);
|
||||
|
||||
// Axios 에러 응답 처리
|
||||
if (error.response?.data) {
|
||||
return error.response.data;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "사원 저장 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 + 부서 정보 조회 (수정 모달용)
|
||||
*
|
||||
* user_info와 user_dept 정보를 함께 조회합니다.
|
||||
*
|
||||
* @param userId 조회할 사용자 ID
|
||||
* @returns 사원 정보 및 부서 관계 정보
|
||||
*/
|
||||
export async function getUserWithDept(userId: string): Promise<ApiResponse<UserWithDeptResponse>> {
|
||||
try {
|
||||
console.log("사원+부서 조회 API 호출:", userId);
|
||||
|
||||
const response = await apiClient.get(`/admin/users/${userId}/with-dept`);
|
||||
|
||||
console.log("사원+부서 조회 API 응답:", response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("사원+부서 조회 API 오류:", error);
|
||||
|
||||
if (error.response?.data) {
|
||||
return error.response.data;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "사원 조회 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 API 객체로 export
|
||||
export const userAPI = {
|
||||
getList: getUserList,
|
||||
|
|
@ -195,4 +316,7 @@ export const userAPI = {
|
|||
getCompanyList: getCompanyList,
|
||||
getDepartmentList: getDepartmentList,
|
||||
checkDuplicateId: checkDuplicateUserId,
|
||||
// 사원 + 부서 통합 관리
|
||||
saveWithDept: saveUserWithDept,
|
||||
getWithDept: getUserWithDept,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
|||
// 🆕 범용 폼 모달 컴포넌트
|
||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||
|
||||
// 🆕 세금계산서 관리 컴포넌트
|
||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ export function ModalRepeaterTableComponent({
|
|||
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
|
||||
|
||||
// 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||
const sourceColumnLabels = componentConfig?.sourceColumnLabels || {};
|
||||
|
||||
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
|
|
@ -546,11 +549,12 @@ export function ModalRepeaterTableComponent({
|
|||
handleChange(newData);
|
||||
};
|
||||
|
||||
// 컬럼명 -> 라벨명 매핑 생성
|
||||
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
|
||||
const columnLabels = columns.reduce((acc, col) => {
|
||||
acc[col.field] = col.label;
|
||||
// sourceColumnLabels에 정의된 라벨 우선 사용
|
||||
acc[col.field] = sourceColumnLabels[col.field] || col.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}, { ...sourceColumnLabels } as Record<string, string>);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ export function ModalRepeaterTableConfigPanel({
|
|||
{/* 소스 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">소스 컬럼</Label>
|
||||
<Label className="text-xs sm:text-sm">소스 컬럼 (항목 검색 모달)</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -533,37 +533,75 @@ export function ModalRepeaterTableConfigPanel({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
모달 테이블에 표시할 컬럼들
|
||||
모달 테이블에 표시할 컬럼과 헤더 라벨을 설정합니다
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{(localConfig.sourceColumns || []).map((column, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateSourceColumn(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div key={index} className="flex items-start gap-2 p-3 border rounded-md bg-background">
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 컬럼 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateSourceColumn(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 라벨 입력 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={localConfig.sourceColumnLabels?.[column] || ""}
|
||||
onChange={(e) => {
|
||||
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
|
||||
if (e.target.value) {
|
||||
newLabels[column] = e.target.value;
|
||||
} else {
|
||||
delete newLabels[column];
|
||||
}
|
||||
updateConfig({ sourceColumnLabels: newLabels });
|
||||
}}
|
||||
placeholder={tableColumns.find(c => c.columnName === column)?.displayName || column || "라벨 입력"}
|
||||
className="h-8 text-xs"
|
||||
disabled={!column}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSourceColumn(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
// 컬럼 삭제 시 해당 라벨도 삭제
|
||||
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
|
||||
delete newLabels[column];
|
||||
updateConfig({ sourceColumnLabels: newLabels });
|
||||
removeSourceColumn(index);
|
||||
}}
|
||||
className="h-8 w-8 p-0 mt-5"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(localConfig.sourceColumns || []).length === 0 && (
|
||||
<div className="text-center py-4 border-2 border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"추가" 버튼을 클릭하여 모달에 표시할 컬럼을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface ModalRepeaterTableProps {
|
|||
// 소스 데이터 (모달에서 가져올 데이터)
|
||||
sourceTable: string; // 검색할 테이블 (예: "item_info")
|
||||
sourceColumns: string[]; // 모달에 표시할 컬럼들
|
||||
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
||||
|
||||
// 🆕 저장 대상 테이블 설정
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ColumnConfig,
|
||||
DataTransferField,
|
||||
ActionButtonConfig,
|
||||
JoinTableConfig,
|
||||
} from "./types";
|
||||
import { defaultConfig } from "./config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -128,6 +129,99 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]);
|
||||
|
||||
// 조인 테이블 데이터 로드 (단일 테이블)
|
||||
const loadJoinTableData = useCallback(async (
|
||||
joinConfig: JoinTableConfig,
|
||||
mainData: any[]
|
||||
): Promise<Map<string, any>> => {
|
||||
const resultMap = new Map<string, any>();
|
||||
if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) {
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
// 메인 데이터에서 조인할 키 값들 추출
|
||||
const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))];
|
||||
if (joinKeys.length === 0) return resultMap;
|
||||
|
||||
try {
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`);
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
// 조인 키 값들로 필터링
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
matchType: "any", // OR 조건으로 여러 키 매칭
|
||||
filters: joinKeys.map((key, idx) => ({
|
||||
id: `join_key_${idx}`,
|
||||
columnName: joinConfig.joinColumn,
|
||||
operator: "equals",
|
||||
value: String(key),
|
||||
valueType: "static",
|
||||
})),
|
||||
},
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
filterType: "company",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const joinData = response.data.data?.data || [];
|
||||
// 조인 컬럼 값을 키로 하는 Map 생성
|
||||
joinData.forEach((item: any) => {
|
||||
const key = item[joinConfig.joinColumn];
|
||||
if (key) {
|
||||
resultMap.set(String(key), item);
|
||||
}
|
||||
});
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error);
|
||||
}
|
||||
|
||||
return resultMap;
|
||||
}, []);
|
||||
|
||||
// 메인 데이터에 조인 테이블 데이터 병합
|
||||
const mergeJoinData = useCallback((
|
||||
mainData: any[],
|
||||
joinConfig: JoinTableConfig,
|
||||
joinDataMap: Map<string, any>
|
||||
): any[] => {
|
||||
return mainData.map((item) => {
|
||||
const joinKey = item[joinConfig.mainColumn];
|
||||
const joinRow = joinDataMap.get(String(joinKey));
|
||||
|
||||
if (joinRow && joinConfig.selectColumns) {
|
||||
// 선택된 컬럼만 병합
|
||||
const mergedItem = { ...item };
|
||||
joinConfig.selectColumns.forEach((col) => {
|
||||
// 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용)
|
||||
const tableColumnKey = `${joinConfig.joinTable}.${col}`;
|
||||
mergedItem[tableColumnKey] = joinRow[col];
|
||||
|
||||
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성)
|
||||
const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col;
|
||||
// 메인 테이블에 같은 컬럼이 없으면 추가
|
||||
if (!(col in mergedItem)) {
|
||||
mergedItem[col] = joinRow[col];
|
||||
} else if (joinConfig.alias) {
|
||||
// 메인 테이블에 같은 컬럼이 있으면 alias로 추가
|
||||
mergedItem[targetKey] = joinRow[col];
|
||||
}
|
||||
});
|
||||
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) });
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 우측 데이터 로드 (좌측 선택 항목 기반)
|
||||
const loadRightData = useCallback(async (selectedItem: any) => {
|
||||
if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) {
|
||||
|
|
@ -173,7 +267,24 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
|
||||
if (response.data.success) {
|
||||
// API 응답 구조: { success: true, data: { data: [...], total, page, ... } }
|
||||
const data = response.data.data?.data || [];
|
||||
let data = response.data.data?.data || [];
|
||||
console.log(`[SplitPanelLayout2] 메인 데이터 로드 완료: ${data.length}건`);
|
||||
|
||||
// 추가 조인 테이블 처리
|
||||
const joinTables = config.rightPanel?.joinTables || [];
|
||||
if (joinTables.length > 0 && data.length > 0) {
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`);
|
||||
|
||||
for (const joinTableConfig of joinTables) {
|
||||
const joinDataMap = await loadJoinTableData(joinTableConfig, data);
|
||||
if (joinDataMap.size > 0) {
|
||||
data = mergeJoinData(data, joinTableConfig, joinDataMap);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`);
|
||||
}
|
||||
|
||||
setRightData(data);
|
||||
console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`);
|
||||
} else {
|
||||
|
|
@ -196,7 +307,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
} finally {
|
||||
setRightLoading(false);
|
||||
}
|
||||
}, [config.rightPanel?.tableName, config.joinConfig]);
|
||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]);
|
||||
|
||||
// 좌측 패널 추가 버튼 클릭
|
||||
const handleLeftAddClick = useCallback(() => {
|
||||
|
|
@ -632,6 +743,37 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
};
|
||||
}, [screenContext, component.id]);
|
||||
|
||||
// 컬럼 값 가져오기 (sourceTable 고려)
|
||||
const getColumnValue = useCallback((item: any, col: ColumnConfig): any => {
|
||||
// col.name이 "테이블명.컬럼명" 형식인 경우 처리
|
||||
const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
||||
const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null;
|
||||
const effectiveSourceTable = col.sourceTable || tableFromName;
|
||||
|
||||
// sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우
|
||||
if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) {
|
||||
// 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식)
|
||||
const tableColumnKey = `${effectiveSourceTable}.${actualColName}`;
|
||||
if (item[tableColumnKey] !== undefined) {
|
||||
return item[tableColumnKey];
|
||||
}
|
||||
// 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도
|
||||
const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable);
|
||||
if (joinTable?.alias) {
|
||||
const aliasKey = `${joinTable.alias}_${actualColName}`;
|
||||
if (item[aliasKey] !== undefined) {
|
||||
return item[aliasKey];
|
||||
}
|
||||
}
|
||||
// 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감)
|
||||
if (item[actualColName] !== undefined) {
|
||||
return item[actualColName];
|
||||
}
|
||||
}
|
||||
// 4. 기본: 컬럼명으로 직접 접근
|
||||
return item[actualColName];
|
||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables]);
|
||||
|
||||
// 값 포맷팅
|
||||
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
|
@ -810,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{nameRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{nameRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
|
|
@ -825,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{infoRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||
{infoRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
|
|
@ -844,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{nameRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{nameRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
if (idx === 0) {
|
||||
return (
|
||||
|
|
@ -865,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{infoRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||
{infoRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="text-sm">
|
||||
|
|
@ -973,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
)}
|
||||
{displayColumns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>
|
||||
{formatValue(item[col.name], col.format)}
|
||||
{formatValue(getColumnValue(item, col), col.format)}
|
||||
</TableCell>
|
||||
))}
|
||||
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types";
|
||||
import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types";
|
||||
|
||||
// lodash set 대체 함수
|
||||
const setPath = (obj: any, path: string, value: any): any => {
|
||||
|
|
@ -245,6 +245,70 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
}
|
||||
}, [config.rightPanel?.tableName, loadColumns]);
|
||||
|
||||
// 조인 테이블 컬럼도 우측 컬럼 목록에 추가
|
||||
useEffect(() => {
|
||||
const loadJoinTableColumns = async () => {
|
||||
const joinTables = config.rightPanel?.joinTables || [];
|
||||
if (joinTables.length === 0 || !config.rightPanel?.tableName) return;
|
||||
|
||||
// 메인 테이블 컬럼 먼저 로드
|
||||
try {
|
||||
const mainResponse = await apiClient.get(`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`);
|
||||
let mainColumns: ColumnInfo[] = [];
|
||||
|
||||
if (mainResponse.data?.success) {
|
||||
const columnList = mainResponse.data.data?.columns || mainResponse.data.data || [];
|
||||
mainColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// 조인 테이블들의 선택된 컬럼 추가
|
||||
const joinColumns: ColumnInfo[] = [];
|
||||
for (const jt of joinTables) {
|
||||
if (jt.joinTable && jt.selectColumns && jt.selectColumns.length > 0) {
|
||||
try {
|
||||
const joinResponse = await apiClient.get(`/table-management/tables/${jt.joinTable}/columns?size=200`);
|
||||
if (joinResponse.data?.success) {
|
||||
const columnList = joinResponse.data.data?.columns || joinResponse.data.data || [];
|
||||
const transformedColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
|
||||
// 선택된 컬럼 추가 (테이블명으로 구분, 유니크 키 생성)
|
||||
jt.selectColumns.forEach((selCol) => {
|
||||
const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol);
|
||||
if (col) {
|
||||
joinColumns.push({
|
||||
...col,
|
||||
// 유니크 키를 위해 테이블명_컬럼명 형태로 저장
|
||||
column_name: `${jt.joinTable}.${col.column_name}`,
|
||||
column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`조인 테이블 ${jt.joinTable} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 + 조인 컬럼 합치기
|
||||
setRightColumns([...mainColumns, ...joinColumns]);
|
||||
console.log(`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`);
|
||||
} catch (error) {
|
||||
console.error("조인 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadJoinTableColumns();
|
||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables]);
|
||||
|
||||
// 테이블 선택 컴포넌트
|
||||
const TableSelect: React.FC<{
|
||||
value: string;
|
||||
|
|
@ -388,13 +452,28 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}> = ({ columns, value, onValueChange, placeholder }) => {
|
||||
showTableName?: boolean; // 테이블명 표시 여부
|
||||
tableName?: string; // 메인 테이블명 (조인 컬럼과 구분용)
|
||||
}> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => {
|
||||
// 현재 선택된 값의 라벨 찾기
|
||||
const selectedColumn = columns.find((col) => col.column_name === value);
|
||||
const displayValue = selectedColumn
|
||||
? selectedColumn.column_comment || selectedColumn.column_name
|
||||
: value || "";
|
||||
|
||||
// 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블)
|
||||
const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")");
|
||||
|
||||
// 컬럼 표시 텍스트 생성
|
||||
const getColumnDisplayText = (col: ColumnInfo) => {
|
||||
const label = col.column_comment || col.column_name;
|
||||
if (showTableName && tableName && !isJoinColumn(col)) {
|
||||
// 메인 테이블 컬럼에 테이블명 추가
|
||||
return `${label} (${tableName})`;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="h-9 text-sm min-w-[120px]">
|
||||
|
|
@ -410,7 +489,16 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_comment || col.column_name}
|
||||
<span className="flex flex-col">
|
||||
<span>{col.column_comment || col.column_name}</span>
|
||||
{showTableName && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{isJoinColumn(col)
|
||||
? col.column_name
|
||||
: `${col.column_name} (${tableName || "메인"})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
|
|
@ -419,6 +507,222 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
);
|
||||
};
|
||||
|
||||
// 조인 테이블 아이템 컴포넌트
|
||||
const JoinTableItem: React.FC<{
|
||||
index: number;
|
||||
joinTable: JoinTableConfig;
|
||||
tables: TableInfo[];
|
||||
mainTableColumns: ColumnInfo[];
|
||||
onUpdate: (field: keyof JoinTableConfig | Partial<JoinTableConfig>, value?: any) => void;
|
||||
onRemove: () => void;
|
||||
}> = ({ index, joinTable, tables, mainTableColumns, onUpdate, onRemove }) => {
|
||||
const [joinTableColumns, setJoinTableColumns] = useState<ColumnInfo[]>([]);
|
||||
const [joinTableOpen, setJoinTableOpen] = useState(false);
|
||||
|
||||
// 조인 테이블 선택 시 해당 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadJoinTableColumns = async () => {
|
||||
if (!joinTable.joinTable) {
|
||||
setJoinTableColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${joinTable.joinTable}/columns?size=200`);
|
||||
let columnList: any[] = [];
|
||||
if (response.data?.success && response.data?.data?.columns) {
|
||||
columnList = response.data.data.columns;
|
||||
} else if (Array.isArray(response.data?.data?.columns)) {
|
||||
columnList = response.data.data.columns;
|
||||
} else if (Array.isArray(response.data?.data)) {
|
||||
columnList = response.data.data;
|
||||
}
|
||||
|
||||
const transformedColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
setJoinTableColumns(transformedColumns);
|
||||
} catch (error) {
|
||||
console.error("조인 테이블 컬럼 로드 실패:", error);
|
||||
setJoinTableColumns([]);
|
||||
}
|
||||
};
|
||||
loadJoinTableColumns();
|
||||
}, [joinTable.joinTable]);
|
||||
|
||||
const selectedTable = tables.find((t) => t.table_name === joinTable.joinTable);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">조인 {index + 1}</span>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onRemove}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조인 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">조인할 테이블</Label>
|
||||
<Popover open={joinTableOpen} onOpenChange={setJoinTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={joinTableOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedTable
|
||||
? selectedTable.table_comment || selectedTable.table_name
|
||||
: joinTable.joinTable || "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.table_name}
|
||||
value={`${table.table_name} ${table.table_comment || ""}`}
|
||||
onSelect={() => {
|
||||
// cmdk가 value를 소문자로 변환하므로 직접 table.table_name 사용
|
||||
// 여러 필드를 한 번에 업데이트 (연속 호출 시 덮어쓰기 방지)
|
||||
onUpdate({
|
||||
joinTable: table.table_name,
|
||||
selectColumns: [], // 테이블 변경 시 선택 컬럼 초기화
|
||||
});
|
||||
setJoinTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
joinTable.joinTable === table.table_name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span>{table.table_comment || table.table_name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{table.table_name}</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 조인 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">조인 방식</Label>
|
||||
<Select
|
||||
value={joinTable.joinType || "LEFT"}
|
||||
onValueChange={(value) => onUpdate("joinType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEFT">LEFT JOIN (데이터 없어도 표시)</SelectItem>
|
||||
<SelectItem value="INNER">INNER JOIN (데이터 있어야만 표시)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">조인 조건</Label>
|
||||
<div className="rounded-md bg-muted/30 p-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">메인 테이블 컬럼</Label>
|
||||
<ColumnSelect
|
||||
columns={mainTableColumns}
|
||||
value={joinTable.mainColumn || ""}
|
||||
onValueChange={(value) => onUpdate("mainColumn", value)}
|
||||
placeholder="메인 테이블 컬럼"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center text-[10px] text-muted-foreground">=</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">조인 테이블 컬럼</Label>
|
||||
<ColumnSelect
|
||||
columns={joinTableColumns}
|
||||
value={joinTable.joinColumn || ""}
|
||||
onValueChange={(value) => onUpdate("joinColumn", value)}
|
||||
placeholder="조인 테이블 컬럼"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가져올 컬럼 선택 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label className="text-xs">가져올 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 text-[10px] px-1"
|
||||
onClick={() => {
|
||||
const current = joinTable.selectColumns || [];
|
||||
onUpdate("selectColumns", [...current, ""]);
|
||||
}}
|
||||
disabled={!joinTable.joinTable}
|
||||
>
|
||||
<Plus className="mr-0.5 h-2.5 w-2.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
조인 테이블에서 표시할 컬럼들을 선택하세요
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{(joinTable.selectColumns || []).map((col, colIndex) => (
|
||||
<div key={colIndex} className="flex items-center gap-1">
|
||||
<ColumnSelect
|
||||
columns={joinTableColumns}
|
||||
value={col}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(joinTable.selectColumns || [])];
|
||||
current[colIndex] = value;
|
||||
onUpdate("selectColumns", current);
|
||||
}}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
const current = joinTable.selectColumns || [];
|
||||
onUpdate(
|
||||
"selectColumns",
|
||||
current.filter((_, i) => i !== colIndex)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(joinTable.selectColumns || []).length === 0 && (
|
||||
<div className="rounded border py-2 text-center text-[10px] text-muted-foreground">
|
||||
가져올 컬럼을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = (side: "left" | "right") => {
|
||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||
|
|
@ -426,7 +730,12 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
? config.leftPanel?.displayColumns || []
|
||||
: config.rightPanel?.displayColumns || [];
|
||||
|
||||
updateConfig(path, [...currentColumns, { name: "", label: "" }]);
|
||||
// 기본 테이블 설정 (메인 테이블)
|
||||
const defaultTable = side === "left"
|
||||
? config.leftPanel?.tableName
|
||||
: config.rightPanel?.tableName;
|
||||
|
||||
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
|
||||
};
|
||||
|
||||
// 표시 컬럼 삭제
|
||||
|
|
@ -440,14 +749,25 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
};
|
||||
|
||||
// 표시 컬럼 업데이트
|
||||
const updateDisplayColumn = (side: "left" | "right", index: number, field: keyof ColumnConfig, value: any) => {
|
||||
const updateDisplayColumn = (
|
||||
side: "left" | "right",
|
||||
index: number,
|
||||
fieldOrPartial: keyof ColumnConfig | Partial<ColumnConfig>,
|
||||
value?: any
|
||||
) => {
|
||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||
const currentColumns = side === "left"
|
||||
? [...(config.leftPanel?.displayColumns || [])]
|
||||
: [...(config.rightPanel?.displayColumns || [])];
|
||||
|
||||
if (currentColumns[index]) {
|
||||
currentColumns[index] = { ...currentColumns[index], [field]: value };
|
||||
if (typeof fieldOrPartial === "object") {
|
||||
// 여러 필드를 한 번에 업데이트
|
||||
currentColumns[index] = { ...currentColumns[index], ...fieldOrPartial };
|
||||
} else {
|
||||
// 단일 필드 업데이트
|
||||
currentColumns[index] = { ...currentColumns[index], [fieldOrPartial]: value };
|
||||
}
|
||||
updateConfig(path, currentColumns);
|
||||
}
|
||||
};
|
||||
|
|
@ -687,6 +1007,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 추가 조인 테이블 설정 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">추가 조인 테이블</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.joinTables || [];
|
||||
updateConfig("rightPanel.joinTables", [
|
||||
...current,
|
||||
{
|
||||
joinTable: "",
|
||||
joinType: "LEFT",
|
||||
mainColumn: "",
|
||||
joinColumn: "",
|
||||
selectColumns: [],
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.joinTables || []).map((joinTable, index) => (
|
||||
<JoinTableItem
|
||||
key={index}
|
||||
index={index}
|
||||
joinTable={joinTable}
|
||||
tables={tables}
|
||||
mainTableColumns={rightColumns}
|
||||
onUpdate={(fieldOrPartial, value) => {
|
||||
const current = [...(config.rightPanel?.joinTables || [])];
|
||||
if (typeof fieldOrPartial === "object") {
|
||||
// 여러 필드를 한 번에 업데이트
|
||||
current[index] = { ...current[index], ...fieldOrPartial };
|
||||
} else {
|
||||
// 단일 필드 업데이트
|
||||
current[index] = { ...current[index], [fieldOrPartial]: value };
|
||||
}
|
||||
updateConfig("rightPanel.joinTables", current);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const current = config.rightPanel?.joinTables || [];
|
||||
updateConfig(
|
||||
"rightPanel.joinTables",
|
||||
current.filter((_, i) => i !== index)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
|
@ -696,52 +1076,148 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{(config.rightPanel?.displayColumns || []).map((col, index) => (
|
||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeDisplayColumn("right", index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
{(config.rightPanel?.displayColumns || []).map((col, index) => {
|
||||
// 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들
|
||||
const availableTables = [
|
||||
config.rightPanel?.tableName,
|
||||
...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// 선택된 테이블의 컬럼만 필터링
|
||||
const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
|
||||
const filteredColumns = rightColumns.filter((c) => {
|
||||
// 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태)
|
||||
const isJoinColumn = c.column_name.includes(".");
|
||||
|
||||
if (selectedSourceTable === config.rightPanel?.tableName) {
|
||||
// 메인 테이블 선택 시: 조인 컬럼 아닌 것만
|
||||
return !isJoinColumn;
|
||||
} else {
|
||||
// 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태)
|
||||
return c.column_name.startsWith(`${selectedSourceTable}.`);
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 라벨 가져오기
|
||||
const getTableLabel = (tableName: string) => {
|
||||
const table = tables.find((t) => t.table_name === tableName);
|
||||
return table?.table_comment || tableName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeDisplayColumn("right", index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">테이블</Label>
|
||||
<Select
|
||||
value={col.sourceTable || config.rightPanel?.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
// 테이블 변경 시 sourceTable과 name을 한 번에 업데이트
|
||||
updateDisplayColumn("right", index, {
|
||||
sourceTable: value,
|
||||
name: "", // 컬럼 초기화
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((tableName) => (
|
||||
<SelectItem key={tableName} value={tableName}>
|
||||
<span className="flex flex-col">
|
||||
<span>{getTableLabel(tableName)}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{tableName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
||||
<Select
|
||||
value={col.name || ""}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredColumns.length === 0 ? (
|
||||
<SelectItem value="_empty" disabled>
|
||||
테이블을 먼저 선택하세요
|
||||
</SelectItem>
|
||||
) : (
|
||||
filteredColumns.map((c) => {
|
||||
// 조인 컬럼의 경우 테이블명 제거하고 표시
|
||||
const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name;
|
||||
// 실제 컬럼명 (테이블명.컬럼명에서 컬럼명만 추출)
|
||||
const actualColumnName = c.column_name.includes(".")
|
||||
? c.column_name.split(".")[1]
|
||||
: c.column_name;
|
||||
return (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
<span className="flex flex-col">
|
||||
<span>{displayLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{actualColumnName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 라벨 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={col.label || ""}
|
||||
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
||||
placeholder="라벨명 (미입력 시 컬럼명 사용)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 표시 위치 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">표시 위치</Label>
|
||||
<Select
|
||||
value={col.displayRow || "info"}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">이름 행 (Name Row)</SelectItem>
|
||||
<SelectItem value="info">정보 행 (Info Row)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<ColumnSelect
|
||||
columns={rightColumns}
|
||||
value={col.name}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={col.label || ""}
|
||||
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
||||
placeholder="라벨명 (미입력 시 컬럼명 사용)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
||||
<Select
|
||||
value={col.displayRow || "info"}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">이름 행 (Name Row)</SelectItem>
|
||||
<SelectItem value="info">정보 행 (Info Row)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
|
||||
표시할 컬럼을 추가하세요
|
||||
|
|
@ -766,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
disabled={(config.rightPanel?.displayColumns || []).length === 0}
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.searchColumns || [];
|
||||
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
|
||||
|
|
@ -775,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<ColumnSelect
|
||||
columns={rightColumns}
|
||||
value={searchCol.columnName}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(config.rightPanel?.searchColumns || [])];
|
||||
current[index] = { ...current[index], columnName: value };
|
||||
updateConfig("rightPanel.searchColumns", current);
|
||||
}}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.searchColumns || [];
|
||||
updateConfig(
|
||||
"rightPanel.searchColumns",
|
||||
current.filter((_, i) => i !== index)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
|
||||
// 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
|
||||
const displayColumns = config.rightPanel?.displayColumns || [];
|
||||
|
||||
// 유효한 컬럼만 필터링 (name이 있는 것만)
|
||||
const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== "");
|
||||
|
||||
// 현재 선택된 컬럼의 표시 정보
|
||||
const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
|
||||
const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
|
||||
const selectedLabel = selectedDisplayCol?.label ||
|
||||
selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
|
||||
searchCol.columnName;
|
||||
const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
|
||||
const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={searchCol.columnName || ""}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(config.rightPanel?.searchColumns || [])];
|
||||
current[index] = { ...current[index], columnName: value };
|
||||
updateConfig("rightPanel.searchColumns", current);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs flex-1">
|
||||
<SelectValue placeholder="컬럼 선택">
|
||||
{searchCol.columnName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{selectedLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({selectedTableLabel})</span>
|
||||
</span>
|
||||
) : (
|
||||
"컬럼 선택"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validDisplayColumns.length === 0 ? (
|
||||
<SelectItem value="_empty" disabled>
|
||||
먼저 표시할 컬럼을 추가하세요
|
||||
</SelectItem>
|
||||
) : (
|
||||
validDisplayColumns.map((dc, dcIndex) => {
|
||||
const colInfo = rightColumns.find((c) => c.column_name === dc.name);
|
||||
const label = dc.label || colInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || dc.name;
|
||||
const tableName = dc.sourceTable || config.rightPanel?.tableName || "";
|
||||
const tableLabel = tables.find((t) => t.table_name === tableName)?.table_comment || tableName;
|
||||
const actualColName = dc.name.includes(".") ? dc.name.split(".")[1] : dc.name;
|
||||
|
||||
return (
|
||||
<SelectItem key={`search-${dc.name}-${dcIndex}`} value={dc.name}>
|
||||
<span className="flex flex-col">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({tableLabel})</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{actualColName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.searchColumns || [];
|
||||
updateConfig(
|
||||
"rightPanel.searchColumns",
|
||||
current.filter((_, i) => i !== index)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
||||
먼저 표시할 컬럼을 추가하세요
|
||||
</div>
|
||||
))}
|
||||
{(config.rightPanel?.searchColumns || []).length === 0 && (
|
||||
)}
|
||||
{(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
|
||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
||||
검색할 컬럼을 추가하세요
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
export interface ColumnConfig {
|
||||
name: string; // 컬럼명
|
||||
label: string; // 표시 라벨
|
||||
sourceTable?: string; // 소스 테이블명 (메인 테이블 또는 조인 테이블)
|
||||
displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행)
|
||||
width?: number; // 너비 (px)
|
||||
bold?: boolean; // 굵게 표시
|
||||
|
|
@ -94,6 +95,17 @@ export interface RightPanelConfig {
|
|||
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
|
||||
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
|
||||
emptyMessage?: string; // 데이터 없을 때 메시지
|
||||
|
||||
/**
|
||||
* 추가 조인 테이블 설정
|
||||
* 메인 테이블에 다른 테이블을 JOIN하여 추가 정보를 함께 표시합니다.
|
||||
*
|
||||
* 사용 예시:
|
||||
* - 메인 테이블: user_dept (부서-사용자 관계)
|
||||
* - 조인 테이블: user_info (사용자 개인정보)
|
||||
* - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시
|
||||
*/
|
||||
joinTables?: JoinTableConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -104,6 +116,27 @@ export interface JoinConfig {
|
|||
rightColumn: string; // 우측 테이블의 조인 컬럼
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 조인 테이블 설정
|
||||
* 우측 패널의 메인 테이블에 다른 테이블을 JOIN하여 추가 컬럼을 가져옵니다.
|
||||
*
|
||||
* 예시: user_dept (메인) + user_info (조인) → 부서관계 + 개인정보 함께 표시
|
||||
*
|
||||
* - joinTable: 조인할 테이블명 (예: user_info)
|
||||
* - joinType: 조인 방식 (LEFT JOIN 권장)
|
||||
* - mainColumn: 메인 테이블의 조인 컬럼 (예: user_id)
|
||||
* - joinColumn: 조인 테이블의 조인 컬럼 (예: user_id)
|
||||
* - selectColumns: 조인 테이블에서 가져올 컬럼들 (예: email, cell_phone)
|
||||
*/
|
||||
export interface JoinTableConfig {
|
||||
joinTable: string; // 조인할 테이블명
|
||||
joinType: "LEFT" | "INNER"; // 조인 타입 (LEFT: 없어도 표시, INNER: 있어야만 표시)
|
||||
mainColumn: string; // 메인 테이블의 조인 컬럼
|
||||
joinColumn: string; // 조인 테이블의 조인 컬럼
|
||||
selectColumns: string[]; // 조인 테이블에서 가져올 컬럼들
|
||||
alias?: string; // 테이블 별칭 (중복 컬럼명 구분용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 설정
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트 (레지스트리용 래퍼)
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { TaxInvoiceList } from "@/components/tax-invoice";
|
||||
import { TaxInvoiceListConfig } from "./types";
|
||||
|
||||
interface TaxInvoiceListComponentProps {
|
||||
config?: TaxInvoiceListConfig;
|
||||
componentId?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function TaxInvoiceListComponent({
|
||||
config,
|
||||
componentId,
|
||||
isEditMode,
|
||||
}: TaxInvoiceListComponentProps) {
|
||||
// 편집 모드에서는 플레이스홀더 표시
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-4xl">📄</div>
|
||||
<p className="text-muted-foreground text-sm font-medium">세금계산서 목록</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config?.title || "세금계산서 관리"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" style={{ height: config?.height || "auto" }}>
|
||||
<TaxInvoiceList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 래퍼 컴포넌트 (레지스트리 호환용)
|
||||
export function TaxInvoiceListWrapper(props: any) {
|
||||
return <TaxInvoiceListComponent {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 설정 패널
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||
|
||||
interface TaxInvoiceListConfigPanelProps {
|
||||
config: TaxInvoiceListConfig;
|
||||
onChange: (config: TaxInvoiceListConfig) => void;
|
||||
}
|
||||
|
||||
export function TaxInvoiceListConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: TaxInvoiceListConfigPanelProps) {
|
||||
const currentConfig = { ...defaultTaxInvoiceListConfig, ...config };
|
||||
|
||||
const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => {
|
||||
onChange({ ...currentConfig, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
placeholder="세금계산서 관리"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">헤더 표시</Label>
|
||||
<Switch
|
||||
checked={currentConfig.showHeader}
|
||||
onCheckedChange={(checked) => handleChange("showHeader", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 필터 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 필터</h4>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">기본 유형</Label>
|
||||
<Select
|
||||
value={currentConfig.defaultInvoiceType}
|
||||
onValueChange={(v) => handleChange("defaultInvoiceType", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">기본 상태</Label>
|
||||
<Select
|
||||
value={currentConfig.defaultStatus}
|
||||
onValueChange={(v) => handleChange("defaultStatus", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="draft">임시저장</SelectItem>
|
||||
<SelectItem value="issued">발행완료</SelectItem>
|
||||
<SelectItem value="sent">전송완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">페이지당 항목 수</Label>
|
||||
<Select
|
||||
value={String(currentConfig.pageSize)}
|
||||
onValueChange={(v) => handleChange("pageSize", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">권한 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">생성 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canCreate}
|
||||
onCheckedChange={(checked) => handleChange("canCreate", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">수정 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canEdit}
|
||||
onCheckedChange={(checked) => handleChange("canEdit", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">삭제 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canDelete}
|
||||
onCheckedChange={(checked) => handleChange("canDelete", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">발행 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canIssue}
|
||||
onCheckedChange={(checked) => handleChange("canIssue", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">취소 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canCancel}
|
||||
onCheckedChange={(checked) => handleChange("canCancel", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TaxInvoiceListDefinition } from "./index";
|
||||
import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TaxInvoiceListDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TaxInvoiceListComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TaxInvoiceListRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
TaxInvoiceListRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("TaxInvoiceList 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||
import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트 정의
|
||||
* 세금계산서 CRUD, 발행, 취소 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const TaxInvoiceListDefinition = createComponentDefinition({
|
||||
id: "tax-invoice-list",
|
||||
name: "세금계산서 목록",
|
||||
nameEng: "Tax Invoice List",
|
||||
description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: TaxInvoiceListWrapper,
|
||||
defaultConfig: defaultTaxInvoiceListConfig,
|
||||
defaultSize: { width: 1200, height: 700 },
|
||||
configPanel: TaxInvoiceListConfigPanel,
|
||||
icon: "FileText",
|
||||
tags: ["세금계산서", "매출", "매입", "발행", "인보이스"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TaxInvoiceListConfig } from "./types";
|
||||
export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||
export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||
export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* 세금계산서 목록 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface TaxInvoiceListConfig {
|
||||
// 기본 설정
|
||||
title?: string;
|
||||
showHeader?: boolean;
|
||||
|
||||
// 필터 설정
|
||||
defaultInvoiceType?: "all" | "sales" | "purchase";
|
||||
defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled";
|
||||
|
||||
// 페이지네이션
|
||||
pageSize?: number;
|
||||
|
||||
// 권한 설정
|
||||
canCreate?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canIssue?: boolean;
|
||||
canCancel?: boolean;
|
||||
|
||||
// 스타일
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = {
|
||||
title: "세금계산서 관리",
|
||||
showHeader: true,
|
||||
defaultInvoiceType: "all",
|
||||
defaultStatus: "all",
|
||||
pageSize: 20,
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canIssue: true,
|
||||
canCancel: true,
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
|
|
@ -100,6 +100,11 @@ export function UniversalFormModalComponent({
|
|||
[key: string]: { value: string; label: string }[];
|
||||
}>({});
|
||||
|
||||
// 연동 필드 그룹 데이터 캐시 (테이블별 데이터)
|
||||
const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{
|
||||
[tableKey: string]: Record<string, any>[];
|
||||
}>({});
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -115,6 +120,33 @@ export function UniversalFormModalComponent({
|
|||
initializeForm();
|
||||
}, [config, initialData]);
|
||||
|
||||
// 필드 레벨 linkedFieldGroup 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const tablesToLoad = new Set<string>();
|
||||
|
||||
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
|
||||
config.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
|
||||
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 각 테이블 데이터 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
if (!linkedFieldDataCache[tableName]) {
|
||||
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
|
||||
await loadLinkedFieldData(tableName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections]);
|
||||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
const newFormData: FormDataState = {};
|
||||
|
|
@ -342,6 +374,56 @@ export function UniversalFormModalComponent({
|
|||
[selectOptionsCache],
|
||||
);
|
||||
|
||||
// 연동 필드 그룹 데이터 로드
|
||||
const loadLinkedFieldData = useCallback(
|
||||
async (sourceTable: string): Promise<Record<string, any>[]> => {
|
||||
// 캐시 확인 - 이미 배열로 캐시되어 있으면 반환
|
||||
if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) {
|
||||
return linkedFieldDataCache[sourceTable];
|
||||
}
|
||||
|
||||
let data: Record<string, any>[] = [];
|
||||
|
||||
try {
|
||||
console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`);
|
||||
// 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용)
|
||||
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
|
||||
});
|
||||
|
||||
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
||||
|
||||
if (response.data?.success) {
|
||||
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
|
||||
const responseData = response.data?.data;
|
||||
if (Array.isArray(responseData)) {
|
||||
// 직접 배열인 경우
|
||||
data = responseData;
|
||||
} else if (responseData?.data && Array.isArray(responseData.data)) {
|
||||
// { data: [...], total: ... } 형태 (tableManagementService 응답)
|
||||
data = responseData.data;
|
||||
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
|
||||
// { rows: [...], total: ... } 형태 (다른 API 응답)
|
||||
data = responseData.rows;
|
||||
}
|
||||
console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3));
|
||||
}
|
||||
|
||||
// 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지)
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data }));
|
||||
} catch (error) {
|
||||
console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error);
|
||||
// 실패해도 빈 배열로 캐시하여 무한 요청 방지
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] }));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[linkedFieldDataCache],
|
||||
);
|
||||
|
||||
// 필수 필드 검증
|
||||
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
|
||||
const missingFields: string[] = [];
|
||||
|
|
@ -362,59 +444,8 @@ export function UniversalFormModalComponent({
|
|||
return { valid: missingFields.length === 0, missingFields };
|
||||
}, [config.sections, formData]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!config.saveConfig.tableName) {
|
||||
toast.error("저장할 테이블이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
const { valid, missingFields } = validateRequiredFields();
|
||||
if (!valid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const { multiRowSave } = config.saveConfig;
|
||||
|
||||
if (multiRowSave?.enabled) {
|
||||
// 다중 행 저장
|
||||
await saveMultipleRows();
|
||||
} else {
|
||||
// 단일 행 저장
|
||||
await saveSingleRow();
|
||||
}
|
||||
|
||||
// 저장 후 동작
|
||||
if (config.saveConfig.afterSave?.showToast) {
|
||||
toast.success("저장되었습니다.");
|
||||
}
|
||||
|
||||
if (config.saveConfig.afterSave?.refreshParent) {
|
||||
window.dispatchEvent(new CustomEvent("refreshParentData"));
|
||||
}
|
||||
|
||||
// onSave 콜백은 저장 완료 알림용으로만 사용
|
||||
// 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
|
||||
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
|
||||
// _saveCompleted 플래그를 포함하여 전달
|
||||
if (onSave) {
|
||||
onSave({ ...formData, _saveCompleted: true });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error(error.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, formData, repeatSections, onSave, validateRequiredFields]);
|
||||
|
||||
// 단일 행 저장
|
||||
const saveSingleRow = async () => {
|
||||
const saveSingleRow = useCallback(async () => {
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 메타데이터 필드 제거
|
||||
|
|
@ -446,15 +477,15 @@ export function UniversalFormModalComponent({
|
|||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
};
|
||||
}, [config.sections, config.saveConfig.tableName, formData]);
|
||||
|
||||
// 다중 행 저장 (겸직 등)
|
||||
const saveMultipleRows = async () => {
|
||||
const saveMultipleRows = useCallback(async () => {
|
||||
const { multiRowSave } = config.saveConfig;
|
||||
if (!multiRowSave) return;
|
||||
|
||||
let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } =
|
||||
multiRowSave;
|
||||
let { commonFields = [], repeatSectionId = "" } = multiRowSave;
|
||||
const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
|
||||
|
||||
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
||||
if (commonFields.length === 0) {
|
||||
|
|
@ -475,56 +506,57 @@ export function UniversalFormModalComponent({
|
|||
// 디버깅: 설정 확인
|
||||
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
|
||||
commonFields,
|
||||
mainSectionFields,
|
||||
repeatSectionId,
|
||||
mainSectionFields,
|
||||
typeColumn,
|
||||
mainTypeValue,
|
||||
subTypeValue,
|
||||
repeatSections,
|
||||
formData,
|
||||
});
|
||||
console.log("[UniversalFormModal] 현재 formData:", formData);
|
||||
|
||||
// 공통 필드 데이터 추출
|
||||
const commonData: Record<string, any> = {};
|
||||
for (const fieldName of commonFields) {
|
||||
// 반복 섹션 데이터
|
||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||
|
||||
// 저장할 행들 생성
|
||||
const rowsToSave: any[] = [];
|
||||
|
||||
// 공통 데이터 (모든 행에 적용)
|
||||
const commonData: any = {};
|
||||
commonFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
commonData[fieldName] = formData[fieldName];
|
||||
}
|
||||
}
|
||||
console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData);
|
||||
});
|
||||
|
||||
// 메인 섹션 필드 데이터 추출
|
||||
const mainSectionData: Record<string, any> = {};
|
||||
if (mainSectionFields && mainSectionFields.length > 0) {
|
||||
for (const fieldName of mainSectionFields) {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
mainSectionData[fieldName] = formData[fieldName];
|
||||
}
|
||||
// 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
|
||||
const mainSectionData: any = {};
|
||||
mainSectionFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
mainSectionData[fieldName] = formData[fieldName];
|
||||
}
|
||||
}
|
||||
console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData);
|
||||
});
|
||||
|
||||
// 저장할 행들 준비
|
||||
const rowsToSave: Record<string, any>[] = [];
|
||||
console.log("[UniversalFormModal] 공통 데이터:", commonData);
|
||||
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
|
||||
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
|
||||
|
||||
// 1. 메인 행 생성
|
||||
const mainRow: Record<string, any> = {
|
||||
...commonData,
|
||||
...mainSectionData,
|
||||
};
|
||||
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
||||
const mainRow: any = { ...commonData, ...mainSectionData };
|
||||
if (typeColumn) {
|
||||
mainRow[typeColumn] = mainTypeValue || "main";
|
||||
}
|
||||
rowsToSave.push(mainRow);
|
||||
|
||||
// 2. 반복 섹션 행들 생성 (겸직 등)
|
||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||
// 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
|
||||
for (const item of repeatItems) {
|
||||
const subRow: Record<string, any> = { ...commonData };
|
||||
const subRow: any = { ...commonData };
|
||||
|
||||
// 반복 섹션 필드 복사
|
||||
Object.keys(item).forEach((key) => {
|
||||
if (!key.startsWith("_")) {
|
||||
subRow[key] = item[key];
|
||||
// 반복 섹션의 필드 값 추가
|
||||
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
||||
repeatSection?.fields.forEach((field) => {
|
||||
if (item[field.columnName] !== undefined) {
|
||||
subRow[field.columnName] = item[field.columnName];
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -578,7 +610,187 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
|
||||
};
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
||||
|
||||
// 커스텀 API 저장 (사원+부서 통합 저장 등)
|
||||
const saveWithCustomApi = useCallback(async () => {
|
||||
const { customApiSave } = config.saveConfig;
|
||||
if (!customApiSave) return;
|
||||
|
||||
console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType);
|
||||
|
||||
const saveUserWithDeptApi = async () => {
|
||||
const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave;
|
||||
|
||||
// 1. userInfo 데이터 구성
|
||||
const userInfo: Record<string, any> = {};
|
||||
|
||||
// 모든 필드에서 user_info에 해당하는 데이터 추출
|
||||
config.sections.forEach((section) => {
|
||||
if (section.repeatable) return; // 반복 섹션은 제외
|
||||
|
||||
section.fields.forEach((field) => {
|
||||
const value = formData[field.columnName];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
userInfo[field.columnName] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. mainDept 데이터 구성
|
||||
let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined;
|
||||
|
||||
if (mainDeptFields) {
|
||||
const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"];
|
||||
if (deptCode) {
|
||||
mainDept = {
|
||||
dept_code: deptCode,
|
||||
dept_name: formData[mainDeptFields.deptNameField || "dept_name"],
|
||||
position_name: formData[mainDeptFields.positionNameField || "position_name"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. subDepts 데이터 구성 (반복 섹션에서)
|
||||
const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = [];
|
||||
|
||||
if (subDeptSectionId && repeatSections[subDeptSectionId]) {
|
||||
const subDeptItems = repeatSections[subDeptSectionId];
|
||||
const deptCodeField = subDeptFields?.deptCodeField || "dept_code";
|
||||
const deptNameField = subDeptFields?.deptNameField || "dept_name";
|
||||
const positionNameField = subDeptFields?.positionNameField || "position_name";
|
||||
|
||||
subDeptItems.forEach((item) => {
|
||||
const deptCode = item[deptCodeField];
|
||||
if (deptCode) {
|
||||
subDepts.push({
|
||||
dept_code: deptCode,
|
||||
dept_name: item[deptNameField],
|
||||
position_name: item[positionNameField],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. API 호출
|
||||
console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts });
|
||||
|
||||
const { saveUserWithDept } = await import("@/lib/api/user");
|
||||
const response = await saveUserWithDept({
|
||||
userInfo: userInfo as any,
|
||||
mainDept,
|
||||
subDepts,
|
||||
isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "사원 저장 실패");
|
||||
}
|
||||
|
||||
console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data);
|
||||
};
|
||||
|
||||
const saveWithGenericCustomApi = async () => {
|
||||
if (!customApiSave.customEndpoint) {
|
||||
throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 메타데이터 필드 제거
|
||||
Object.keys(dataToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 반복 섹션 데이터 포함
|
||||
if (Object.keys(repeatSections).length > 0) {
|
||||
dataToSave._repeatSections = repeatSections;
|
||||
}
|
||||
|
||||
const method = customApiSave.customMethod || "POST";
|
||||
const response = method === "PUT"
|
||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
switch (customApiSave.apiType) {
|
||||
case "user-with-dept":
|
||||
await saveUserWithDeptApi();
|
||||
break;
|
||||
case "custom":
|
||||
await saveWithGenericCustomApi();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
||||
}
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(async () => {
|
||||
// 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크
|
||||
if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) {
|
||||
toast.error("저장할 테이블이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
const { valid, missingFields } = validateRequiredFields();
|
||||
if (!valid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const { multiRowSave, customApiSave } = config.saveConfig;
|
||||
|
||||
// 커스텀 API 저장 모드
|
||||
if (customApiSave?.enabled) {
|
||||
await saveWithCustomApi();
|
||||
} else if (multiRowSave?.enabled) {
|
||||
// 다중 행 저장
|
||||
await saveMultipleRows();
|
||||
} else {
|
||||
// 단일 행 저장
|
||||
await saveSingleRow();
|
||||
}
|
||||
|
||||
// 저장 후 동작
|
||||
if (config.saveConfig.afterSave?.showToast) {
|
||||
toast.success("저장되었습니다.");
|
||||
}
|
||||
|
||||
if (config.saveConfig.afterSave?.refreshParent) {
|
||||
window.dispatchEvent(new CustomEvent("refreshParentData"));
|
||||
}
|
||||
|
||||
// onSave 콜백은 저장 완료 알림용으로만 사용
|
||||
// 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
|
||||
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
|
||||
// _saveCompleted 플래그를 포함하여 전달
|
||||
if (onSave) {
|
||||
onSave({ ...formData, _saveCompleted: true });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("저장 실패:", error);
|
||||
// axios 에러의 경우 서버 응답 메시지 추출
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error?.details ||
|
||||
error.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]);
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
|
|
@ -624,7 +836,88 @@ export function UniversalFormModalComponent({
|
|||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "select": {
|
||||
// 다중 컬럼 저장이 활성화된 경우
|
||||
const lfgMappings = field.linkedFieldGroup?.mappings;
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
||||
const lfg = field.linkedFieldGroup;
|
||||
const sourceTableName = lfg.sourceTable as string;
|
||||
const cachedData = linkedFieldDataCache[sourceTableName];
|
||||
const sourceData = Array.isArray(cachedData) ? cachedData : [];
|
||||
|
||||
// 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용
|
||||
const valueColumn = lfgMappings[0].sourceColumn || "";
|
||||
|
||||
// 데이터 로드 (아직 없으면)
|
||||
if (!cachedData && sourceTableName) {
|
||||
loadLinkedFieldData(sourceTableName);
|
||||
}
|
||||
|
||||
// 표시 텍스트 생성 함수
|
||||
const getDisplayText = (row: Record<string, unknown>): string => {
|
||||
const displayVal = row[lfg.displayColumn || ""] || "";
|
||||
const valueVal = row[valueColumn] || "";
|
||||
switch (lfg.displayFormat) {
|
||||
case "code_name":
|
||||
return `${valueVal} - ${displayVal}`;
|
||||
case "name_code":
|
||||
return `${displayVal} (${valueVal})`;
|
||||
case "name_only":
|
||||
default:
|
||||
return String(displayVal);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(selectedValue) => {
|
||||
// 선택된 값에 해당하는 행 찾기
|
||||
const selectedRow = sourceData.find((row) => String(row[valueColumn]) === selectedValue);
|
||||
|
||||
// 기본 필드 값 변경 (첫 번째 매핑의 값)
|
||||
onChangeHandler(selectedValue);
|
||||
|
||||
// 매핑된 컬럼들도 함께 저장
|
||||
if (selectedRow && lfg.mappings) {
|
||||
lfg.mappings.forEach((mapping) => {
|
||||
if (mapping.sourceColumn && mapping.targetColumn) {
|
||||
const mappedValue = selectedRow[mapping.sourceColumn];
|
||||
// formData에 직접 저장
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[mapping.targetColumn]: mappedValue,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger id={fieldKey} className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceData.length > 0 ? (
|
||||
sourceData.map((row, index) => (
|
||||
<SelectItem
|
||||
key={`${row[valueColumn] || index}_${index}`}
|
||||
value={String(row[valueColumn] || "")}
|
||||
>
|
||||
{getDisplayText(row)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="_empty" disabled>
|
||||
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 select 필드
|
||||
return (
|
||||
<SelectField
|
||||
fieldId={fieldKey}
|
||||
|
|
@ -636,6 +929,7 @@ export function UniversalFormModalComponent({
|
|||
loadOptions={loadSelectOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
return (
|
||||
|
|
@ -806,6 +1100,7 @@ export function UniversalFormModalComponent({
|
|||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -827,6 +1122,7 @@ export function UniversalFormModalComponent({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -885,6 +1181,7 @@ export function UniversalFormModalComponent({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@ import {
|
|||
UniversalFormModalConfigPanelProps,
|
||||
FormSectionConfig,
|
||||
FormFieldConfig,
|
||||
LinkedFieldMapping,
|
||||
FIELD_TYPE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
SELECT_OPTION_TYPE_OPTIONS,
|
||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultFieldConfig,
|
||||
|
|
@ -87,6 +89,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.saveConfig.tableName]);
|
||||
|
||||
// 다중 컬럼 저장의 소스 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const allSourceTables = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
// 필드 레벨의 linkedFieldGroup 확인
|
||||
section.fields.forEach((field) => {
|
||||
if (field.linkedFieldGroup?.sourceTable) {
|
||||
allSourceTables.add(field.linkedFieldGroup.sourceTable);
|
||||
}
|
||||
});
|
||||
});
|
||||
allSourceTables.forEach((tableName) => {
|
||||
if (!tableColumns[tableName]) {
|
||||
loadTableColumns(tableName);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
|
|
@ -395,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
{/* 저장 테이블 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-[10px]">저장 테이블</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="w-full h-7 justify-between text-xs mt-1"
|
||||
>
|
||||
{config.saveConfig.tableName
|
||||
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
||||
config.saveConfig.tableName
|
||||
: "테이블 선택 또는 직접 입력"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.name}
|
||||
value={`${t.name} ${t.label}`}
|
||||
onSelect={() => {
|
||||
updateSaveConfig({ tableName: t.name });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.label !== t.name && (
|
||||
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.saveConfig.tableName && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
컬럼 {currentColumns.length}개 로드됨
|
||||
</p>
|
||||
{config.saveConfig.customApiSave?.enabled ? (
|
||||
<div className="mt-1 p-2 bg-muted/50 rounded text-[10px] text-muted-foreground">
|
||||
전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다.
|
||||
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
|
||||
<span className="block mt-1">대상 테이블: user_info + user_dept</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="w-full h-7 justify-between text-xs mt-1"
|
||||
>
|
||||
{config.saveConfig.tableName
|
||||
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
||||
config.saveConfig.tableName
|
||||
: "테이블 선택 또는 직접 입력"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.name}
|
||||
value={`${t.name} ${t.label}`}
|
||||
onSelect={() => {
|
||||
updateSaveConfig({ tableName: t.name });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.label !== t.name && (
|
||||
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.saveConfig.tableName && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
컬럼 {currentColumns.length}개 로드됨
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 다중 행 저장 설정 */}
|
||||
{/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */}
|
||||
{!config.saveConfig.customApiSave?.enabled && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">다중 행 저장</span>
|
||||
|
|
@ -554,47 +587,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
</Select>
|
||||
<HelpText>겸직 등 반복 데이터가 있는 섹션</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
{/* 커스텀 API 저장 설정 */}
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">전용 API 저장</span>
|
||||
<Switch
|
||||
checked={config.saveConfig.customApiSave?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다.</HelpText>
|
||||
|
||||
{config.saveConfig.customApiSave?.enabled && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
{/* API 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">구분 컬럼</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.typeColumn || "employment_type"}
|
||||
onChange={(e) =>
|
||||
<Label className="text-[10px]">API 타입</Label>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.apiType || "user-with-dept"}
|
||||
onValueChange={(value: "user-with-dept" | "custom") =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value },
|
||||
customApiSave: { ...config.saveConfig.customApiSave, apiType: value },
|
||||
})
|
||||
}
|
||||
placeholder="employment_type"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>메인/서브를 구분하는 컬럼명</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">메인 값</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.mainTypeValue || "main"}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">서브 값</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.subTypeValue || "concurrent"}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user-with-dept">사원+부서 통합 저장</SelectItem>
|
||||
<SelectItem value="custom">커스텀 API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 사원+부서 통합 저장 설정 */}
|
||||
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
|
||||
<div className="space-y-2 p-2 bg-muted/30 rounded">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
user_info와 user_dept 테이블에 트랜잭션으로 저장합니다.
|
||||
메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다.
|
||||
</p>
|
||||
|
||||
{/* 메인 부서 필드 매핑 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">메인 부서 필드 매핑</Label>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">부서코드:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.mainDeptFields?.deptCodeField || "dept_code"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
mainDeptFields: {
|
||||
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||
deptCodeField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.filter((s) => !s.repeatable)
|
||||
.flatMap((s) => s.fields)
|
||||
.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">부서명:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.mainDeptFields?.deptNameField || "dept_name"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
mainDeptFields: {
|
||||
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||
deptNameField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.filter((s) => !s.repeatable)
|
||||
.flatMap((s) => s.fields)
|
||||
.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">직급:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.mainDeptFields?.positionNameField || "position_name"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
mainDeptFields: {
|
||||
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||
positionNameField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.filter((s) => !s.repeatable)
|
||||
.flatMap((s) => s.fields)
|
||||
.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 겸직 부서 반복 섹션 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">겸직 부서 반복 섹션</Label>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.subDeptSectionId || ""}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: { ...config.saveConfig.customApiSave, subDeptSectionId: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px]">
|
||||
<SelectValue placeholder="반복 섹션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.filter((s) => s.repeatable)
|
||||
.map((section) => (
|
||||
<SelectItem key={section.id} value={section.id}>
|
||||
{section.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 겸직 부서 필드 매핑 */}
|
||||
{config.saveConfig.customApiSave?.subDeptSectionId && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">겸직 부서 필드 매핑</Label>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">부서코드:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.subDeptFields?.deptCodeField || "dept_code"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
subDeptFields: {
|
||||
...config.saveConfig.customApiSave?.subDeptFields,
|
||||
deptCodeField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||
?.fields.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">부서명:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.subDeptFields?.deptNameField || "dept_name"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
subDeptFields: {
|
||||
...config.saveConfig.customApiSave?.subDeptFields,
|
||||
deptNameField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||
?.fields.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] w-16 shrink-0">직급:</span>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.subDeptFields?.positionNameField || "position_name"}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...config.saveConfig.customApiSave,
|
||||
subDeptFields: {
|
||||
...config.saveConfig.customApiSave?.subDeptFields,
|
||||
positionNameField: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.sections
|
||||
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||
?.fields.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커스텀 API 설정 */}
|
||||
{config.saveConfig.customApiSave?.apiType === "custom" && (
|
||||
<div className="space-y-2 p-2 bg-muted/30 rounded">
|
||||
<div>
|
||||
<Label className="text-[10px]">API 엔드포인트</Label>
|
||||
<Input
|
||||
value={config.saveConfig.customApiSave?.customEndpoint || ""}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="/api/custom/endpoint"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.customMethod || "POST"}
|
||||
onValueChange={(value: "POST" | "PUT") =>
|
||||
updateSaveConfig({
|
||||
customApiSave: { ...config.saveConfig.customApiSave, customMethod: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -659,7 +966,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
<Card
|
||||
key={section.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
"cursor-pointer transition-colors !p-0",
|
||||
selectedSectionId === section.id && "ring-2 ring-primary",
|
||||
)}
|
||||
onClick={() => {
|
||||
|
|
@ -1144,7 +1451,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
{/* Select 옵션 설정 */}
|
||||
{selectedField.fieldType === "select" && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<Label className="text-[10px] font-medium">선택 옵션 설정</Label>
|
||||
<Label className="text-[10px] font-medium">드롭다운 옵션 설정</Label>
|
||||
<HelpText>드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다.</HelpText>
|
||||
<Select
|
||||
value={selectedField.selectOptions?.type || "static"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1168,10 +1476,15 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedField.selectOptions?.type === "static" && (
|
||||
<HelpText>직접 입력: 옵션을 수동으로 입력합니다. (현재 미구현 - 테이블 참조 사용 권장)</HelpText>
|
||||
)}
|
||||
|
||||
{selectedField.selectOptions?.type === "table" && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||
<div>
|
||||
<Label className="text-[10px]">참조 테이블</Label>
|
||||
<Label className="text-[10px]">참조 테이블 (옵션을 가져올 테이블)</Label>
|
||||
<Select
|
||||
value={selectedField.selectOptions?.tableName || ""}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1194,9 +1507,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>예: dept_info (부서 테이블)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">값 컬럼</Label>
|
||||
<Label className="text-[10px]">값 컬럼 (저장될 값)</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.valueColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1207,12 +1521,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="code"
|
||||
placeholder="dept_code"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>선택 시 실제 저장되는 값 (예: D001)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">라벨 컬럼</Label>
|
||||
<Label className="text-[10px]">라벨 컬럼 (화면에 표시될 텍스트)</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.labelColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1223,15 +1538,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="name"
|
||||
placeholder="dept_name"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>드롭다운에 보여질 텍스트 (예: 영업부)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedField.selectOptions?.type === "code" && (
|
||||
<div className="pt-2 border-t">
|
||||
<HelpText>공통코드: 공통코드 테이블에서 옵션을 가져옵니다.</HelpText>
|
||||
<Label className="text-[10px]">공통코드 카테고리</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.codeCategory || ""}
|
||||
|
|
@ -1246,6 +1563,235 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
placeholder="POSITION_CODE"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>예: POSITION_CODE (직급), STATUS_CODE (상태) 등</HelpText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다중 컬럼 저장 (select 타입만) */}
|
||||
{selectedField.fieldType === "select" && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">다중 컬럼 저장</span>
|
||||
<Switch
|
||||
checked={selectedField.linkedFieldGroup?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
enabled: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
드롭다운 선택 시 여러 컬럼에 동시 저장합니다.
|
||||
<br />예: 부서 선택 시 부서코드 + 부서명을 각각 다른 컬럼에 저장
|
||||
</HelpText>
|
||||
|
||||
{selectedField.linkedFieldGroup?.enabled && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
{/* 소스 테이블 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">데이터 소스 테이블</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.sourceTable || ""}
|
||||
onValueChange={(value) => {
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
sourceTable: value,
|
||||
},
|
||||
});
|
||||
if (value && !tableColumns[value]) {
|
||||
loadTableColumns(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>드롭다운 옵션을 가져올 테이블</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 표시 형식 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">드롭다운 표시 형식</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.displayFormat || "name_only"}
|
||||
onValueChange={(value: "name_only" | "code_name" | "name_code") =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
displayFormat: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 / 값 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 컬럼 (사용자에게 보여줄 텍스트)</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.displayColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
displayColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>사용자가 드롭다운에서 보게 될 텍스트 (예: 영업부, 개발부)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장할 컬럼 매핑 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">저장할 컬럼 매핑</Label>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newMapping: LinkedFieldMapping = { sourceColumn: "", targetColumn: "" };
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: [...(selectedField.linkedFieldGroup?.mappings || []), newMapping],
|
||||
},
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>드롭다운 선택 시 소스 테이블의 어떤 값을 어떤 컬럼에 저장할지 설정</HelpText>
|
||||
|
||||
{(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => (
|
||||
<div key={mappingIndex} className="bg-muted/30 p-1.5 rounded space-y-1 border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-muted-foreground">매핑 #{mappingIndex + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 text-destructive"
|
||||
onClick={() => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).filter(
|
||||
(_, i) => i !== mappingIndex
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">가져올 컬럼 (소스 테이블)</Label>
|
||||
<Select
|
||||
value={mapping.sourceColumn}
|
||||
onValueChange={(value) => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
|
||||
i === mappingIndex ? { ...m, sourceColumn: value } : m
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="소스 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">저장할 컬럼 (저장 테이블)</Label>
|
||||
<Select
|
||||
value={mapping.targetColumn}
|
||||
onValueChange={(value) => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
|
||||
i === mappingIndex ? { ...m, targetColumn: value } : m
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="저장할 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[config.saveConfig.tableName] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground text-center py-2">
|
||||
+ 버튼을 눌러 매핑을 추가하세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,27 @@ export const defaultSectionConfig = {
|
|||
itemTitle: "항목 {index}",
|
||||
confirmRemove: false,
|
||||
},
|
||||
linkedFieldGroups: [],
|
||||
};
|
||||
|
||||
// 기본 연동 필드 그룹 설정
|
||||
export const defaultLinkedFieldGroupConfig = {
|
||||
id: "",
|
||||
label: "연동 필드",
|
||||
sourceTable: "dept_info",
|
||||
displayFormat: "code_name" as const,
|
||||
displayColumn: "dept_name",
|
||||
valueColumn: "dept_code",
|
||||
mappings: [],
|
||||
required: false,
|
||||
placeholder: "선택하세요",
|
||||
gridSpan: 6,
|
||||
};
|
||||
|
||||
// 기본 연동 필드 매핑 설정
|
||||
export const defaultLinkedFieldMappingConfig = {
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
|
||||
// 기본 채번규칙 설정
|
||||
|
|
@ -136,3 +157,8 @@ export const generateSectionId = (): string => {
|
|||
export const generateFieldId = (): string => {
|
||||
return generateUniqueId("field");
|
||||
};
|
||||
|
||||
// 유틸리티: 연동 필드 그룹 ID 생성
|
||||
export const generateLinkedFieldGroupId = (): string => {
|
||||
return generateUniqueId("linked");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ export interface FormFieldConfig {
|
|||
// Select 옵션
|
||||
selectOptions?: SelectOptionConfig;
|
||||
|
||||
// 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장)
|
||||
linkedFieldGroup?: {
|
||||
enabled?: boolean; // 사용 여부
|
||||
sourceTable?: string; // 소스 테이블 (예: dept_info)
|
||||
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
|
||||
displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식
|
||||
mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨)
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
validation?: FieldValidationConfig;
|
||||
|
||||
|
|
@ -96,6 +105,27 @@ export interface FormFieldConfig {
|
|||
};
|
||||
}
|
||||
|
||||
// 연동 필드 매핑 설정
|
||||
export interface LinkedFieldMapping {
|
||||
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
||||
targetColumn: string; // 저장할 컬럼 (예: "position_code")
|
||||
}
|
||||
|
||||
// 연동 필드 그룹 설정 (섹션 레벨)
|
||||
// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장
|
||||
export interface LinkedFieldGroup {
|
||||
id: string;
|
||||
label: string; // 드롭다운 라벨 (예: "겸직부서")
|
||||
sourceTable: string; // 소스 테이블 (예: "dept_info")
|
||||
displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식
|
||||
displayColumn: string; // 표시할 컬럼 (예: "dept_name")
|
||||
valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code")
|
||||
mappings: LinkedFieldMapping[]; // 필드 매핑 목록
|
||||
required?: boolean; // 필수 여부
|
||||
placeholder?: string; // 플레이스홀더
|
||||
gridSpan?: number; // 그리드 스팬 (1-12)
|
||||
}
|
||||
|
||||
// 반복 섹션 설정
|
||||
export interface RepeatSectionConfig {
|
||||
minItems?: number; // 최소 항목 수 (기본: 0)
|
||||
|
|
@ -119,6 +149,9 @@ export interface FormSectionConfig {
|
|||
repeatable?: boolean;
|
||||
repeatConfig?: RepeatSectionConfig;
|
||||
|
||||
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
||||
linkedFieldGroups?: LinkedFieldGroup[];
|
||||
|
||||
// 섹션 레이아웃
|
||||
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
|
||||
gap?: string; // 필드 간 간격
|
||||
|
|
@ -145,6 +178,9 @@ export interface SaveConfig {
|
|||
// 다중 행 저장 설정
|
||||
multiRowSave?: MultiRowSaveConfig;
|
||||
|
||||
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||
customApiSave?: CustomApiSaveConfig;
|
||||
|
||||
// 저장 후 동작 (간편 설정)
|
||||
showToast?: boolean; // 토스트 메시지 (기본: true)
|
||||
refreshParent?: boolean; // 부모 새로고침 (기본: true)
|
||||
|
|
@ -158,6 +194,44 @@ export interface SaveConfig {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 API 저장 설정
|
||||
*
|
||||
* 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다.
|
||||
* 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다.
|
||||
*
|
||||
* ## 지원하는 API 타입
|
||||
* - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept)
|
||||
*
|
||||
* ## 데이터 매핑 설정
|
||||
* - `userInfoFields`: user_info 테이블에 저장할 필드 매핑
|
||||
* - `mainDeptFields`: 메인 부서 정보 필드 매핑
|
||||
* - `subDeptSectionId`: 겸직 부서 반복 섹션 ID
|
||||
*/
|
||||
export interface CustomApiSaveConfig {
|
||||
enabled: boolean;
|
||||
apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입
|
||||
|
||||
// user-with-dept 전용 설정
|
||||
userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName)
|
||||
mainDeptFields?: {
|
||||
deptCodeField?: string; // 메인 부서코드 필드명
|
||||
deptNameField?: string; // 메인 부서명 필드명
|
||||
positionNameField?: string; // 메인 직급 필드명
|
||||
};
|
||||
subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID
|
||||
subDeptFields?: {
|
||||
deptCodeField?: string; // 겸직 부서코드 필드명
|
||||
deptNameField?: string; // 겸직 부서명 필드명
|
||||
positionNameField?: string; // 겸직 직급 필드명
|
||||
};
|
||||
|
||||
// 커스텀 API 전용 설정
|
||||
customEndpoint?: string; // 커스텀 API 엔드포인트
|
||||
customMethod?: "POST" | "PUT"; // HTTP 메서드
|
||||
customDataTransform?: string; // 데이터 변환 함수명 (추후 확장)
|
||||
}
|
||||
|
||||
// 모달 설정
|
||||
export interface ModalConfig {
|
||||
title: string;
|
||||
|
|
@ -257,3 +331,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
|
|||
{ value: "table", label: "테이블 참조" },
|
||||
{ value: "code", label: "공통코드" },
|
||||
] as const;
|
||||
|
||||
// 연동 필드 표시 형식 옵션
|
||||
export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
|
||||
{ value: "name_only", label: "이름만 (예: 영업부)" },
|
||||
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
|
||||
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
|
||||
] as const;
|
||||
|
|
|
|||
Loading…
Reference in New Issue