Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-12-08 16:37:13 +09:00
commit ad5c7f643c
33 changed files with 6878 additions and 300 deletions

View File

@ -74,6 +74,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -240,6 +241,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common"; import { ApiResponse } from "../types/common";
import { Client } from "pg"; import { Client } from "pg";
import { query, queryOne } from "../database/db"; import { query, queryOne, getPool } from "../database/db";
import config from "../config/environment"; import config from "../config/environment";
import { AdminService } from "../services/adminService"; import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil"; 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 },
});
}
}

View File

@ -67,7 +67,7 @@ export class TableHistoryController {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
// 이력 조회 쿼리 // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
const historyQuery = ` const historyQuery = `
SELECT SELECT
log_id, log_id,
@ -84,7 +84,7 @@ export class TableHistoryController {
full_row_after full_row_after
FROM ${logTableName} FROM ${logTableName}
WHERE ${whereClause} WHERE ${whereClause}
ORDER BY changed_at DESC ORDER BY log_id DESC
LIMIT ${limitParam} OFFSET ${offsetParam} LIMIT ${limitParam} OFFSET ${offsetParam}
`; `;
@ -196,7 +196,7 @@ export class TableHistoryController {
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
// 이력 조회 쿼리 // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
const historyQuery = ` const historyQuery = `
SELECT SELECT
log_id, log_id,
@ -213,7 +213,7 @@ export class TableHistoryController {
full_row_after full_row_after
FROM ${logTableName} FROM ${logTableName}
${whereClause} ${whereClause}
ORDER BY changed_at DESC ORDER BY log_id DESC
LIMIT ${limitParam} OFFSET ${offsetParam} LIMIT ${limitParam} OFFSET ${offsetParam}
`; `;

View File

@ -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 || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -28,6 +28,16 @@ export const errorHandler = (
// PostgreSQL 에러 처리 (pg 라이브러리) // PostgreSQL 에러 처리 (pg 라이브러리)
if ((err as any).code) { if ((err as any).code) {
const pgError = err as any; 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 // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") { if (pgError.code === "23505") {
// unique_violation // unique_violation
@ -42,7 +52,7 @@ export const errorHandler = (
// 기타 무결성 제약 조건 위반 // 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
} else { } else {
error = new AppError("데이터베이스 오류가 발생했습니다.", 500); error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
} }
} }

View File

@ -18,6 +18,8 @@ import {
getDepartmentList, // 부서 목록 조회 getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크 checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정 saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!)
getCompanyList, getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회 getCompanyByCode, // 회사 단건 조회
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
router.get("/users", getUserList); router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정 (기존) router.post("/users", saveUser); // 사용자 등록/수정 (기존)
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정 router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크

View File

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

View File

@ -1160,7 +1160,15 @@ export class DynamicFormService {
console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📝 실행할 DELETE SQL:", deleteQuery);
console.log("📊 SQL 파라미터:", [id]); 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); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

View File

@ -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),
},
};
}
}

View File

@ -6,6 +6,11 @@
*/ */
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
} from "@/components/ui/dialog";
import { import {
ResizableDialog, ResizableDialog,
ResizableDialogContent, ResizableDialogContent,
@ -137,7 +142,9 @@ export function TableHistoryModal({
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
try { 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 { } catch {
return dateString; return dateString;
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,4 @@
export { TaxInvoiceList } from "./TaxInvoiceList";
export { TaxInvoiceForm } from "./TaxInvoiceForm";
export { TaxInvoiceDetail } from "./TaxInvoiceDetail";

View File

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

View File

@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) {
return response.data; 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 // 사용자 API 객체로 export
export const userAPI = { export const userAPI = {
getList: getUserList, getList: getUserList,
@ -195,4 +316,7 @@ export const userAPI = {
getCompanyList: getCompanyList, getCompanyList: getCompanyList,
getDepartmentList: getDepartmentList, getDepartmentList: getDepartmentList,
checkDuplicateId: checkDuplicateUserId, checkDuplicateId: checkDuplicateUserId,
// 사원 + 부서 통합 관리
saveWithDept: saveUserWithDept,
getWithDept: getUserWithDept,
}; };

View File

@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
// 🆕 범용 폼 모달 컴포넌트 // 🆕 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원 import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
// 🆕 세금계산서 관리 컴포넌트
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
/** /**
* *
*/ */

View File

@ -185,6 +185,9 @@ export function ModalRepeaterTableComponent({
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || []; const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== ""); const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
// 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
const sourceColumnLabels = componentConfig?.sourceColumnLabels || {};
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || []; const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색"; const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색"; const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
@ -546,11 +549,12 @@ export function ModalRepeaterTableComponent({
handleChange(newData); handleChange(newData);
}; };
// 컬럼명 -> 라벨명 매핑 생성 // 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
const columnLabels = columns.reduce((acc, col) => { const columnLabels = columns.reduce((acc, col) => {
acc[col.field] = col.label; // sourceColumnLabels에 정의된 라벨 우선 사용
acc[col.field] = sourceColumnLabels[col.field] || col.label;
return acc; return acc;
}, {} as Record<string, string>); }, { ...sourceColumnLabels } as Record<string, string>);
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>

View File

@ -520,7 +520,7 @@ export function ModalRepeaterTableConfigPanel({
{/* 소스 컬럼 */} {/* 소스 컬럼 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> ( )</Label>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -533,37 +533,75 @@ export function ModalRepeaterTableConfigPanel({
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
</p> </p>
<div className="space-y-2"> <div className="space-y-3">
{(localConfig.sourceColumns || []).map((column, index) => ( {(localConfig.sourceColumns || []).map((column, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-start gap-2 p-3 border rounded-md bg-background">
<Select <div className="flex-1 space-y-2">
value={column} {/* 컬럼 선택 */}
onValueChange={(value) => updateSourceColumn(index, value)} <div className="space-y-1">
disabled={!localConfig.sourceTable || isLoadingColumns} <Label className="text-[10px] text-muted-foreground"></Label>
> <Select
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1"> value={column}
<SelectValue placeholder="컬럼 선택" /> onValueChange={(value) => updateSourceColumn(index, value)}
</SelectTrigger> disabled={!localConfig.sourceTable || isLoadingColumns}
<SelectContent> >
{tableColumns.map((col) => ( <SelectTrigger className="h-8 text-xs">
<SelectItem key={col.columnName} value={col.columnName}> <SelectValue placeholder="컬럼 선택" />
{col.displayName || col.columnName} </SelectTrigger>
</SelectItem> <SelectContent>
))} {tableColumns.map((col) => (
</SelectContent> <SelectItem key={col.columnName} value={col.columnName}>
</Select> {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 <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => removeSourceColumn(index)} onClick={() => {
className="h-8 w-8 p-0" // 컬럼 삭제 시 해당 라벨도 삭제
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" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </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>
</div> </div>

View File

@ -7,6 +7,7 @@ export interface ModalRepeaterTableProps {
// 소스 데이터 (모달에서 가져올 데이터) // 소스 데이터 (모달에서 가져올 데이터)
sourceTable: string; // 검색할 테이블 (예: "item_info") sourceTable: string; // 검색할 테이블 (예: "item_info")
sourceColumns: string[]; // 모달에 표시할 컬럼들 sourceColumns: string[]; // 모달에 표시할 컬럼들
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
sourceSearchFields?: string[]; // 검색 가능한 필드들 sourceSearchFields?: string[]; // 검색 가능한 필드들
// 🆕 저장 대상 테이블 설정 // 🆕 저장 대상 테이블 설정

View File

@ -7,6 +7,7 @@ import {
ColumnConfig, ColumnConfig,
DataTransferField, DataTransferField,
ActionButtonConfig, ActionButtonConfig,
JoinTableConfig,
} from "./types"; } from "./types";
import { defaultConfig } from "./config"; import { defaultConfig } from "./config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -128,6 +129,99 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
} }
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]); }, [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) => { const loadRightData = useCallback(async (selectedItem: any) => {
if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { 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) { if (response.data.success) {
// API 응답 구조: { success: true, data: { data: [...], total, page, ... } } // 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); setRightData(data);
console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}`); console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}`);
} else { } else {
@ -196,7 +307,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
} finally { } finally {
setRightLoading(false); setRightLoading(false);
} }
}, [config.rightPanel?.tableName, config.joinConfig]); }, [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]);
// 좌측 패널 추가 버튼 클릭 // 좌측 패널 추가 버튼 클릭
const handleLeftAddClick = useCallback(() => { const handleLeftAddClick = useCallback(() => {
@ -632,6 +743,37 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
}; };
}, [screenContext, component.id]); }, [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 => { const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
@ -810,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{nameRowColumns.length > 0 && ( {nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => { {nameRowColumns.map((col, idx) => {
const value = item[col.name]; const value = getColumnValue(item, col);
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
return ( return (
<span key={idx} className="flex items-center gap-1"> <span key={idx} className="flex items-center gap-1">
@ -825,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{infoRowColumns.length > 0 && ( {infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => { {infoRowColumns.map((col, idx) => {
const value = item[col.name]; const value = getColumnValue(item, col);
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
return ( return (
<span key={idx} className="flex items-center gap-1"> <span key={idx} className="flex items-center gap-1">
@ -844,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{nameRowColumns.length > 0 && ( {nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => { {nameRowColumns.map((col, idx) => {
const value = item[col.name]; const value = getColumnValue(item, col);
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
if (idx === 0) { if (idx === 0) {
return ( return (
@ -865,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{infoRowColumns.length > 0 && ( {infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => { {infoRowColumns.map((col, idx) => {
const value = item[col.name]; const value = getColumnValue(item, col);
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
return ( return (
<span key={idx} className="text-sm"> <span key={idx} className="text-sm">
@ -973,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
)} )}
{displayColumns.map((col, colIdx) => ( {displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}> <TableCell key={colIdx}>
{formatValue(item[col.name], col.format)} {formatValue(getColumnValue(item, col), col.format)}
</TableCell> </TableCell>
))} ))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (

View File

@ -28,7 +28,7 @@ import {
import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types";
// lodash set 대체 함수 // lodash set 대체 함수
const setPath = (obj: any, path: string, value: any): any => { const setPath = (obj: any, path: string, value: any): any => {
@ -245,6 +245,70 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
} }
}, [config.rightPanel?.tableName, loadColumns]); }, [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<{ const TableSelect: React.FC<{
value: string; value: string;
@ -388,13 +452,28 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
placeholder: string; 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 selectedColumn = columns.find((col) => col.column_name === value);
const displayValue = selectedColumn const displayValue = selectedColumn
? selectedColumn.column_comment || selectedColumn.column_name ? selectedColumn.column_comment || selectedColumn.column_name
: value || ""; : 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 ( return (
<Select value={value || ""} onValueChange={onValueChange}> <Select value={value || ""} onValueChange={onValueChange}>
<SelectTrigger className="h-9 text-sm min-w-[120px]"> <SelectTrigger className="h-9 text-sm min-w-[120px]">
@ -410,7 +489,16 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
) : ( ) : (
columns.map((col) => ( columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}> <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> </SelectItem>
)) ))
)} )}
@ -419,14 +507,235 @@ 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 addDisplayColumn = (side: "left" | "right") => {
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
const currentColumns = side === "left" const currentColumns = side === "left"
? config.leftPanel?.displayColumns || [] ? config.leftPanel?.displayColumns || []
: config.rightPanel?.displayColumns || []; : config.rightPanel?.displayColumns || [];
// 기본 테이블 설정 (메인 테이블)
const defaultTable = side === "left"
? config.leftPanel?.tableName
: config.rightPanel?.tableName;
updateConfig(path, [...currentColumns, { name: "", label: "" }]); 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 path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
const currentColumns = side === "left" const currentColumns = side === "left"
? [...(config.leftPanel?.displayColumns || [])] ? [...(config.leftPanel?.displayColumns || [])]
: [...(config.rightPanel?.displayColumns || [])]; : [...(config.rightPanel?.displayColumns || [])];
if (currentColumns[index]) { 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); updateConfig(path, currentColumns);
} }
}; };
@ -687,6 +1007,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
/> />
</div> </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>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@ -696,52 +1076,148 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="space-y-3"> <div className="space-y-3">
{(config.rightPanel?.displayColumns || []).map((col, index) => ( {(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"> const availableTables = [
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span> config.rightPanel?.tableName,
<Button ...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable),
size="sm" ].filter(Boolean) as string[];
variant="ghost"
className="h-6 w-6 p-0" // 선택된 테이블의 컬럼만 필터링
onClick={() => removeDisplayColumn("right", index)} const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
> const filteredColumns = rightColumns.filter((c) => {
<X className="h-3 w-3" /> // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태)
</Button> 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> </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 && ( {(config.rightPanel?.displayColumns || []).length === 0 && (
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md"> <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" size="sm"
variant="ghost" variant="ghost"
className="h-6 text-xs" className="h-6 text-xs"
disabled={(config.rightPanel?.displayColumns || []).length === 0}
onClick={() => { onClick={() => {
const current = config.rightPanel?.searchColumns || []; const current = config.rightPanel?.searchColumns || [];
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]); updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
@ -775,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="space-y-2"> <div className="space-y-2">
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( {(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
<div key={index} className="flex items-center gap-2"> // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
<ColumnSelect const displayColumns = config.rightPanel?.displayColumns || [];
columns={rightColumns}
value={searchCol.columnName} // 유효한 컬럼만 필터링 (name이 있는 것만)
onValueChange={(value) => { const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== "");
const current = [...(config.rightPanel?.searchColumns || [])];
current[index] = { ...current[index], columnName: value }; // 현재 선택된 컬럼의 표시 정보
updateConfig("rightPanel.searchColumns", current); const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
}} const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
placeholder="컬럼 선택" const selectedLabel = selectedDisplayCol?.label ||
/> selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
<Button searchCol.columnName;
size="sm" const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
variant="ghost" const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
className="h-8 w-8 shrink-0 p-0"
onClick={() => { return (
const current = config.rightPanel?.searchColumns || []; <div key={index} className="flex items-center gap-2">
updateConfig( <Select
"rightPanel.searchColumns", value={searchCol.columnName || ""}
current.filter((_, i) => i !== index) onValueChange={(value) => {
); const current = [...(config.rightPanel?.searchColumns || [])];
}} current[index] = { ...current[index], columnName: value };
> updateConfig("rightPanel.searchColumns", current);
<X className="h-3 w-3" /> }}
</Button> >
<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> </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 className="rounded-md border py-3 text-center text-xs text-muted-foreground">
</div> </div>

View File

@ -9,6 +9,7 @@
export interface ColumnConfig { export interface ColumnConfig {
name: string; // 컬럼명 name: string; // 컬럼명
label: string; // 표시 라벨 label: string; // 표시 라벨
sourceTable?: string; // 소스 테이블명 (메인 테이블 또는 조인 테이블)
displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행)
width?: number; // 너비 (px) width?: number; // 너비 (px)
bold?: boolean; // 굵게 표시 bold?: boolean; // 굵게 표시
@ -94,6 +95,17 @@ export interface RightPanelConfig {
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
emptyMessage?: string; // 데이터 없을 때 메시지 emptyMessage?: string; // 데이터 없을 때 메시지
/**
*
* JOIN하여 .
*
* :
* - 테이블: user_dept (- )
* - 테이블: user_info ( )
* - 결과: 부서별 ,
*/
joinTables?: JoinTableConfig[];
} }
/** /**
@ -104,6 +116,27 @@ export interface JoinConfig {
rightColumn: string; // 우측 테이블의 조인 컬럼 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; // 테이블 별칭 (중복 컬럼명 구분용)
}
/** /**
* *
*/ */

View File

@ -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} />;
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

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

View File

@ -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",
};

View File

@ -100,6 +100,11 @@ export function UniversalFormModalComponent({
[key: string]: { value: string; label: string }[]; [key: string]: { value: string; label: string }[];
}>({}); }>({});
// 연동 필드 그룹 데이터 캐시 (테이블별 데이터)
const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{
[tableKey: string]: Record<string, any>[];
}>({});
// 로딩 상태 // 로딩 상태
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -115,6 +120,33 @@ export function UniversalFormModalComponent({
initializeForm(); initializeForm();
}, [config, initialData]); }, [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 initializeForm = useCallback(async () => {
const newFormData: FormDataState = {}; const newFormData: FormDataState = {};
@ -342,6 +374,56 @@ export function UniversalFormModalComponent({
[selectOptionsCache], [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 validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = []; const missingFields: string[] = [];
@ -362,59 +444,8 @@ export function UniversalFormModalComponent({
return { valid: missingFields.length === 0, missingFields }; return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]); }, [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 }; const dataToSave = { ...formData };
// 메타데이터 필드 제거 // 메타데이터 필드 제거
@ -446,15 +477,15 @@ export function UniversalFormModalComponent({
if (!response.data?.success) { if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패"); throw new Error(response.data?.message || "저장 실패");
} }
}; }, [config.sections, config.saveConfig.tableName, formData]);
// 다중 행 저장 (겸직 등) // 다중 행 저장 (겸직 등)
const saveMultipleRows = async () => { const saveMultipleRows = useCallback(async () => {
const { multiRowSave } = config.saveConfig; const { multiRowSave } = config.saveConfig;
if (!multiRowSave) return; if (!multiRowSave) return;
let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = let { commonFields = [], repeatSectionId = "" } = multiRowSave;
multiRowSave; const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
if (commonFields.length === 0) { if (commonFields.length === 0) {
@ -475,56 +506,57 @@ export function UniversalFormModalComponent({
// 디버깅: 설정 확인 // 디버깅: 설정 확인
console.log("[UniversalFormModal] 다중 행 저장 설정:", { console.log("[UniversalFormModal] 다중 행 저장 설정:", {
commonFields, commonFields,
mainSectionFields,
repeatSectionId, repeatSectionId,
mainSectionFields,
typeColumn, typeColumn,
mainTypeValue, mainTypeValue,
subTypeValue, subTypeValue,
repeatSections,
formData,
}); });
console.log("[UniversalFormModal] 현재 formData:", formData);
// 공통 필드 데이터 추출 // 반복 섹션 데이터
const commonData: Record<string, any> = {}; const repeatItems = repeatSections[repeatSectionId] || [];
for (const fieldName of commonFields) {
// 저장할 행들 생성
const rowsToSave: any[] = [];
// 공통 데이터 (모든 행에 적용)
const commonData: any = {};
commonFields.forEach((fieldName) => {
if (formData[fieldName] !== undefined) { if (formData[fieldName] !== undefined) {
commonData[fieldName] = formData[fieldName]; commonData[fieldName] = formData[fieldName];
} }
} });
console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData);
// 메인 섹션 필드 데이터 추출 // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
const mainSectionData: Record<string, any> = {}; const mainSectionData: any = {};
if (mainSectionFields && mainSectionFields.length > 0) { mainSectionFields.forEach((fieldName) => {
for (const fieldName of mainSectionFields) { if (formData[fieldName] !== undefined) {
if (formData[fieldName] !== undefined) { mainSectionData[fieldName] = formData[fieldName];
mainSectionData[fieldName] = formData[fieldName];
}
} }
} });
console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData);
// 저장할 행들 준비 console.log("[UniversalFormModal] 공통 데이터:", commonData);
const rowsToSave: Record<string, any>[] = []; console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
// 1. 메인 행 생성 // 메인 행 (공통 데이터 + 메인 섹션 필드)
const mainRow: Record<string, any> = { const mainRow: any = { ...commonData, ...mainSectionData };
...commonData,
...mainSectionData,
};
if (typeColumn) { if (typeColumn) {
mainRow[typeColumn] = mainTypeValue || "main"; mainRow[typeColumn] = mainTypeValue || "main";
} }
rowsToSave.push(mainRow); rowsToSave.push(mainRow);
// 2. 반복 섹션 행들 생성 (겸직 등) // 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
const repeatItems = repeatSections[repeatSectionId] || [];
for (const item of repeatItems) { for (const item of repeatItems) {
const subRow: Record<string, any> = { ...commonData }; const subRow: any = { ...commonData };
// 반복 섹션 필드 복사 // 반복 섹션의 필드 값 추가
Object.keys(item).forEach((key) => { const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
if (!key.startsWith("_")) { repeatSection?.fields.forEach((field) => {
subRow[key] = item[key]; if (item[field.columnName] !== undefined) {
subRow[field.columnName] = item[field.columnName];
} }
}); });
@ -578,7 +610,187 @@ export function UniversalFormModalComponent({
} }
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); 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(() => { const handleReset = useCallback(() => {
@ -624,7 +836,88 @@ export function UniversalFormModalComponent({
</div> </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 ( return (
<SelectField <SelectField
fieldId={fieldKey} fieldId={fieldKey}
@ -636,6 +929,7 @@ export function UniversalFormModalComponent({
loadOptions={loadSelectOptions} loadOptions={loadSelectOptions}
/> />
); );
}
case "date": case "date":
return ( return (
@ -806,6 +1100,7 @@ export function UniversalFormModalComponent({
<CollapsibleContent> <CollapsibleContent>
<CardContent> <CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) => {section.fields.map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
@ -827,6 +1122,7 @@ export function UniversalFormModalComponent({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) => {section.fields.map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,
@ -885,6 +1181,7 @@ export function UniversalFormModalComponent({
</Button> </Button>
</div> </div>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}> <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) => {section.fields.map((field) =>
renderFieldWithColumns( renderFieldWithColumns(
field, field,

View File

@ -37,9 +37,11 @@ import {
UniversalFormModalConfigPanelProps, UniversalFormModalConfigPanelProps,
FormSectionConfig, FormSectionConfig,
FormFieldConfig, FormFieldConfig,
LinkedFieldMapping,
FIELD_TYPE_OPTIONS, FIELD_TYPE_OPTIONS,
MODAL_SIZE_OPTIONS, MODAL_SIZE_OPTIONS,
SELECT_OPTION_TYPE_OPTIONS, SELECT_OPTION_TYPE_OPTIONS,
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "./types"; } from "./types";
import { import {
defaultFieldConfig, defaultFieldConfig,
@ -87,6 +89,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.saveConfig.tableName]); }, [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 () => { const loadTables = async () => {
try { try {
const response = await apiClient.get("/table-management/tables"); const response = await apiClient.get("/table-management/tables");
@ -395,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{/* 저장 테이블 - Combobox */} {/* 저장 테이블 - Combobox */}
<div> <div>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}> {config.saveConfig.customApiSave?.enabled ? (
<PopoverTrigger asChild> <div className="mt-1 p-2 bg-muted/50 rounded text-[10px] text-muted-foreground">
<Button API API가 .
variant="outline" {config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
role="combobox" <span className="block mt-1"> 테이블: user_info + user_dept</span>
aria-expanded={tableSelectOpen} )}
className="w-full h-7 justify-between text-xs mt-1" </div>
> ) : (
{config.saveConfig.tableName <>
? tables.find((t) => t.name === config.saveConfig.tableName)?.label || <Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
config.saveConfig.tableName <PopoverTrigger asChild>
: "테이블 선택 또는 직접 입력"} <Button
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> variant="outline"
</Button> role="combobox"
</PopoverTrigger> aria-expanded={tableSelectOpen}
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> className="w-full h-7 justify-between text-xs mt-1"
<Command> >
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" /> {config.saveConfig.tableName
<CommandList> ? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
<CommandEmpty className="text-xs py-2 text-center"> </CommandEmpty> config.saveConfig.tableName
<CommandGroup> : "테이블 선택 또는 직접 입력"}
{tables.map((t) => ( <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
<CommandItem </Button>
key={t.name} </PopoverTrigger>
value={`${t.name} ${t.label}`} <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
onSelect={() => { <Command>
updateSaveConfig({ tableName: t.name }); <CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
setTableSelectOpen(false); <CommandList>
}} <CommandEmpty className="text-xs py-2 text-center"> </CommandEmpty>
className="text-xs" <CommandGroup>
> {tables.map((t) => (
<Check <CommandItem
className={cn( key={t.name}
"mr-2 h-3 w-3", value={`${t.name} ${t.label}`}
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0", onSelect={() => {
)} updateSaveConfig({ tableName: t.name });
/> setTableSelectOpen(false);
<span className="font-medium">{t.name}</span> }}
{t.label !== t.name && ( className="text-xs"
<span className="ml-1 text-muted-foreground">({t.label})</span> >
)} <Check
</CommandItem> className={cn(
))} "mr-2 h-3 w-3",
</CommandGroup> config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
</CommandList> )}
</Command> />
</PopoverContent> <span className="font-medium">{t.name}</span>
</Popover> {t.label !== t.name && (
{config.saveConfig.tableName && ( <span className="ml-1 text-muted-foreground">({t.label})</span>
<p className="text-[10px] text-muted-foreground mt-1"> )}
{currentColumns.length} </CommandItem>
</p> ))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.saveConfig.tableName && (
<p className="text-[10px] text-muted-foreground mt-1">
{currentColumns.length}
</p>
)}
</>
)} )}
</div> </div>
{/* 다중 행 저장 설정 */} {/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */}
{!config.saveConfig.customApiSave?.enabled && (
<div className="border rounded-md p-2 space-y-2"> <div className="border rounded-md p-2 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span> <span className="text-[10px] font-medium"> </span>
@ -554,47 +587,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</Select> </Select>
<HelpText> </HelpText> <HelpText> </HelpText>
</div> </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> <div>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]">API </Label>
<Input <Select
value={config.saveConfig.multiRowSave?.typeColumn || "employment_type"} value={config.saveConfig.customApiSave?.apiType || "user-with-dept"}
onChange={(e) => onValueChange={(value: "user-with-dept" | "custom") =>
updateSaveConfig({ 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" <SelectTrigger className="h-6 text-[10px] mt-1">
/> <SelectValue />
<HelpText>/ </HelpText> </SelectTrigger>
</div> <SelectContent>
<div> <SelectItem value="user-with-dept">+ </SelectItem>
<Label className="text-[10px]"> </Label> <SelectItem value="custom"> API</SelectItem>
<Input </SelectContent>
value={config.saveConfig.multiRowSave?.mainTypeValue || "main"} </Select>
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"
/>
</div> </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>
)} )}
</div> </div>
@ -659,7 +966,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
<Card <Card
key={section.id} key={section.id}
className={cn( className={cn(
"cursor-pointer transition-colors", "cursor-pointer transition-colors !p-0",
selectedSectionId === section.id && "ring-2 ring-primary", selectedSectionId === section.id && "ring-2 ring-primary",
)} )}
onClick={() => { onClick={() => {
@ -1144,7 +1451,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{/* Select 옵션 설정 */} {/* Select 옵션 설정 */}
{selectedField.fieldType === "select" && ( {selectedField.fieldType === "select" && (
<div className="border rounded-md p-2 space-y-2"> <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 <Select
value={selectedField.selectOptions?.type || "static"} value={selectedField.selectOptions?.type || "static"}
onValueChange={(value) => onValueChange={(value) =>
@ -1168,10 +1476,15 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</SelectContent> </SelectContent>
</Select> </Select>
{selectedField.selectOptions?.type === "static" && (
<HelpText> 입력: 옵션을 . ( - )</HelpText>
)}
{selectedField.selectOptions?.type === "table" && ( {selectedField.selectOptions?.type === "table" && (
<div className="space-y-2 pt-2 border-t"> <div className="space-y-2 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
<div> <div>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> ( )</Label>
<Select <Select
value={selectedField.selectOptions?.tableName || ""} value={selectedField.selectOptions?.tableName || ""}
onValueChange={(value) => onValueChange={(value) =>
@ -1194,9 +1507,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<HelpText>: dept_info ( )</HelpText>
</div> </div>
<div> <div>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> ( )</Label>
<Input <Input
value={selectedField.selectOptions?.valueColumn || ""} value={selectedField.selectOptions?.valueColumn || ""}
onChange={(e) => onChange={(e) =>
@ -1207,12 +1521,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
}, },
}) })
} }
placeholder="code" placeholder="dept_code"
className="h-6 text-[10px] mt-1" className="h-6 text-[10px] mt-1"
/> />
<HelpText> (: D001)</HelpText>
</div> </div>
<div> <div>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> ( )</Label>
<Input <Input
value={selectedField.selectOptions?.labelColumn || ""} value={selectedField.selectOptions?.labelColumn || ""}
onChange={(e) => onChange={(e) =>
@ -1223,15 +1538,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
}, },
}) })
} }
placeholder="name" placeholder="dept_name"
className="h-6 text-[10px] mt-1" className="h-6 text-[10px] mt-1"
/> />
<HelpText> (: 영업부)</HelpText>
</div> </div>
</div> </div>
)} )}
{selectedField.selectOptions?.type === "code" && ( {selectedField.selectOptions?.type === "code" && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<HelpText>공통코드: 공통코드 .</HelpText>
<Label className="text-[10px]"> </Label> <Label className="text-[10px]"> </Label>
<Input <Input
value={selectedField.selectOptions?.codeCategory || ""} value={selectedField.selectOptions?.codeCategory || ""}
@ -1246,6 +1563,235 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
placeholder="POSITION_CODE" placeholder="POSITION_CODE"
className="h-6 text-[10px] mt-1" 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>
)} )}
</div> </div>

View File

@ -90,6 +90,27 @@ export const defaultSectionConfig = {
itemTitle: "항목 {index}", itemTitle: "항목 {index}",
confirmRemove: false, 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 => { export const generateFieldId = (): string => {
return generateUniqueId("field"); return generateUniqueId("field");
}; };
// 유틸리티: 연동 필드 그룹 ID 생성
export const generateLinkedFieldGroupId = (): string => {
return generateUniqueId("linked");
};

View File

@ -74,6 +74,15 @@ export interface FormFieldConfig {
// Select 옵션 // Select 옵션
selectOptions?: SelectOptionConfig; 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; 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 { export interface RepeatSectionConfig {
minItems?: number; // 최소 항목 수 (기본: 0) minItems?: number; // 최소 항목 수 (기본: 0)
@ -119,6 +149,9 @@ export interface FormSectionConfig {
repeatable?: boolean; repeatable?: boolean;
repeatConfig?: RepeatSectionConfig; repeatConfig?: RepeatSectionConfig;
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
linkedFieldGroups?: LinkedFieldGroup[];
// 섹션 레이아웃 // 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2) columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격 gap?: string; // 필드 간 간격
@ -145,6 +178,9 @@ export interface SaveConfig {
// 다중 행 저장 설정 // 다중 행 저장 설정
multiRowSave?: MultiRowSaveConfig; multiRowSave?: MultiRowSaveConfig;
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
customApiSave?: CustomApiSaveConfig;
// 저장 후 동작 (간편 설정) // 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true) showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: 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 { export interface ModalConfig {
title: string; title: string;
@ -257,3 +331,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
{ value: "table", label: "테이블 참조" }, { value: "table", label: "테이블 참조" },
{ value: "code", label: "공통코드" }, { value: "code", label: "공통코드" },
] as const; ] 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;