Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
ad5c7f643c
|
|
@ -74,6 +74,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import 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); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 컨트롤러
|
||||||
|
* 세금계산서 API 엔드포인트 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { TaxInvoiceService } from "../services/taxInvoiceService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
companyCode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaxInvoiceController {
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 조회
|
||||||
|
* GET /api/tax-invoice
|
||||||
|
*/
|
||||||
|
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = "1",
|
||||||
|
pageSize = "20",
|
||||||
|
invoice_type,
|
||||||
|
invoice_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
search,
|
||||||
|
buyer_name,
|
||||||
|
cost_type,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getList(companyCode, {
|
||||||
|
page: parseInt(page as string, 10),
|
||||||
|
pageSize: parseInt(pageSize as string, 10),
|
||||||
|
invoice_type: invoice_type as "sales" | "purchase" | undefined,
|
||||||
|
invoice_status: invoice_status as string | undefined,
|
||||||
|
start_date: start_date as string | undefined,
|
||||||
|
end_date: end_date as string | undefined,
|
||||||
|
search: search as string | undefined,
|
||||||
|
buyer_name: buyer_name as string | undefined,
|
||||||
|
cost_type: cost_type as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
total: result.total,
|
||||||
|
totalPages: Math.ceil(result.total / result.pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 조회
|
||||||
|
* GET /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.getById(id, companyCode);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 상세 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 생성
|
||||||
|
* POST /api/tax-invoice
|
||||||
|
*/
|
||||||
|
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!data.invoice_type) {
|
||||||
|
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.invoice_date) {
|
||||||
|
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.supply_amount === undefined || data.supply_amount === null) {
|
||||||
|
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.create(data, companyCode, userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 생성되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수정
|
||||||
|
* PUT /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 수정 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 삭제
|
||||||
|
* DELETE /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.delete(id, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "세금계산서가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 삭제 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 발행
|
||||||
|
* POST /api/tax-invoice/:id/issue
|
||||||
|
*/
|
||||||
|
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.issue(id, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 발행되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 발행 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 취소
|
||||||
|
* POST /api/tax-invoice/:id/cancel
|
||||||
|
*/
|
||||||
|
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 취소되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 취소 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
* GET /api/tax-invoice/stats/monthly
|
||||||
|
*/
|
||||||
|
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
|
||||||
|
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
period: { year: targetYear, month: targetMonth },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("월별 통계 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 조회
|
||||||
|
* GET /api/tax-invoice/stats/cost-type
|
||||||
|
*/
|
||||||
|
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const targetYear = year ? parseInt(year as string, 10) : undefined;
|
||||||
|
const targetMonth = month ? parseInt(month as string, 10) : undefined;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
period: { year: targetYear, month: targetMonth },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("비용 유형별 통계 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +28,16 @@ export const errorHandler = (
|
||||||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 라우터
|
||||||
|
* /api/tax-invoice 경로 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
router.get("/", TaxInvoiceController.getList);
|
||||||
|
|
||||||
|
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
||||||
|
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
||||||
|
|
||||||
|
// 비용 유형별 통계
|
||||||
|
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
||||||
|
|
||||||
|
// 상세 조회
|
||||||
|
router.get("/:id", TaxInvoiceController.getById);
|
||||||
|
|
||||||
|
// 생성
|
||||||
|
router.post("/", TaxInvoiceController.create);
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
router.put("/:id", TaxInvoiceController.update);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
router.delete("/:id", TaxInvoiceController.delete);
|
||||||
|
|
||||||
|
// 발행
|
||||||
|
router.post("/:id/issue", TaxInvoiceController.issue);
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -1160,7 +1160,15 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
console.log("📝 실행할 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,784 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 서비스
|
||||||
|
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, transaction } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// 비용 유형 타입
|
||||||
|
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||||
|
|
||||||
|
// 세금계산서 타입 정의
|
||||||
|
export interface TaxInvoice {
|
||||||
|
id: string;
|
||||||
|
company_code: string;
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_type: "sales" | "purchase"; // 매출/매입
|
||||||
|
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||||
|
|
||||||
|
// 공급자 정보
|
||||||
|
supplier_business_no: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_ceo_name: string;
|
||||||
|
supplier_address: string;
|
||||||
|
supplier_business_type: string;
|
||||||
|
supplier_business_item: string;
|
||||||
|
|
||||||
|
// 공급받는자 정보
|
||||||
|
buyer_business_no: string;
|
||||||
|
buyer_name: string;
|
||||||
|
buyer_ceo_name: string;
|
||||||
|
buyer_address: string;
|
||||||
|
buyer_email: string;
|
||||||
|
|
||||||
|
// 금액 정보
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
|
||||||
|
// 날짜 정보
|
||||||
|
invoice_date: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
|
||||||
|
// 기타
|
||||||
|
remarks: string;
|
||||||
|
order_id: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
|
||||||
|
// 첨부파일 (JSON 배열로 저장)
|
||||||
|
attachments: TaxInvoiceAttachment[] | null;
|
||||||
|
|
||||||
|
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
|
||||||
|
cost_type: CostType | null;
|
||||||
|
|
||||||
|
created_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
writer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 타입
|
||||||
|
export interface TaxInvoiceAttachment {
|
||||||
|
id: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
file_type: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
uploaded_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxInvoiceItem {
|
||||||
|
id: string;
|
||||||
|
tax_invoice_id: string;
|
||||||
|
company_code: string;
|
||||||
|
item_seq: number;
|
||||||
|
item_date: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxInvoiceDto {
|
||||||
|
invoice_type: "sales" | "purchase";
|
||||||
|
supplier_business_no?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
supplier_ceo_name?: string;
|
||||||
|
supplier_address?: string;
|
||||||
|
supplier_business_type?: string;
|
||||||
|
supplier_business_item?: string;
|
||||||
|
buyer_business_no?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
buyer_ceo_name?: string;
|
||||||
|
buyer_address?: string;
|
||||||
|
buyer_email?: string;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
invoice_date: string;
|
||||||
|
remarks?: string;
|
||||||
|
order_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
items?: CreateTaxInvoiceItemDto[];
|
||||||
|
attachments?: TaxInvoiceAttachment[]; // 첨부파일
|
||||||
|
cost_type?: CostType; // 비용 유형
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxInvoiceItemDto {
|
||||||
|
item_date?: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxInvoiceListParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
invoice_type?: "sales" | "purchase";
|
||||||
|
invoice_status?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
cost_type?: CostType; // 비용 유형 필터
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaxInvoiceService {
|
||||||
|
/**
|
||||||
|
* 세금계산서 번호 채번
|
||||||
|
* 형식: YYYYMM-NNNNN (예: 202512-00001)
|
||||||
|
*/
|
||||||
|
static async generateInvoiceNumber(companyCode: string): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const prefix = `${yearMonth}-`;
|
||||||
|
|
||||||
|
// 해당 월의 마지막 번호 조회
|
||||||
|
const result = await query<{ max_num: string }>(
|
||||||
|
`SELECT invoice_number as max_num
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND invoice_number LIKE $2
|
||||||
|
ORDER BY invoice_number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[companyCode, `${prefix}%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextNum = 1;
|
||||||
|
if (result.length > 0 && result[0].max_num) {
|
||||||
|
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
|
||||||
|
nextNum = lastNum + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${String(nextNum).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 조회
|
||||||
|
*/
|
||||||
|
static async getList(
|
||||||
|
companyCode: string,
|
||||||
|
params: TaxInvoiceListParams
|
||||||
|
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
invoice_type,
|
||||||
|
invoice_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
search,
|
||||||
|
buyer_name,
|
||||||
|
cost_type,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const values: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (invoice_type) {
|
||||||
|
conditions.push(`invoice_type = $${paramIndex}`);
|
||||||
|
values.push(invoice_type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_status) {
|
||||||
|
conditions.push(`invoice_status = $${paramIndex}`);
|
||||||
|
values.push(invoice_status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start_date) {
|
||||||
|
conditions.push(`invoice_date >= $${paramIndex}`);
|
||||||
|
values.push(start_date);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end_date) {
|
||||||
|
conditions.push(`invoice_date <= $${paramIndex}`);
|
||||||
|
values.push(end_date);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
|
values.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buyer_name) {
|
||||||
|
conditions.push(`buyer_name ILIKE $${paramIndex}`);
|
||||||
|
values.push(`%${buyer_name}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost_type) {
|
||||||
|
conditions.push(`cost_type = $${paramIndex}`);
|
||||||
|
values.push(cost_type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countResult = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult[0]?.count || "0", 10);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
values.push(pageSize, offset);
|
||||||
|
const data = await query<TaxInvoice>(
|
||||||
|
`SELECT * FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY created_date DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 조회 (품목 포함)
|
||||||
|
*/
|
||||||
|
static async getById(
|
||||||
|
id: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
|
||||||
|
const invoiceResult = await query<TaxInvoice>(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoiceResult.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await query<TaxInvoiceItem>(
|
||||||
|
`SELECT * FROM tax_invoice_item
|
||||||
|
WHERE tax_invoice_id = $1 AND company_code = $2
|
||||||
|
ORDER BY item_seq`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { invoice: invoiceResult[0], items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 생성
|
||||||
|
*/
|
||||||
|
static async create(
|
||||||
|
data: CreateTaxInvoiceDto,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<TaxInvoice> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 세금계산서 번호 채번
|
||||||
|
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
|
||||||
|
|
||||||
|
// 세금계산서 생성
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`INSERT INTO tax_invoice (
|
||||||
|
company_code, invoice_number, invoice_type, invoice_status,
|
||||||
|
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
|
||||||
|
supplier_business_type, supplier_business_item,
|
||||||
|
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
|
||||||
|
supply_amount, tax_amount, total_amount, invoice_date,
|
||||||
|
remarks, order_id, customer_id, attachments, cost_type, writer
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 'draft',
|
||||||
|
$4, $5, $6, $7, $8, $9,
|
||||||
|
$10, $11, $12, $13, $14,
|
||||||
|
$15, $16, $17, $18,
|
||||||
|
$19, $20, $21, $22, $23, $24
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
invoiceNumber,
|
||||||
|
data.invoice_type,
|
||||||
|
data.supplier_business_no || null,
|
||||||
|
data.supplier_name || null,
|
||||||
|
data.supplier_ceo_name || null,
|
||||||
|
data.supplier_address || null,
|
||||||
|
data.supplier_business_type || null,
|
||||||
|
data.supplier_business_item || null,
|
||||||
|
data.buyer_business_no || null,
|
||||||
|
data.buyer_name || null,
|
||||||
|
data.buyer_ceo_name || null,
|
||||||
|
data.buyer_address || null,
|
||||||
|
data.buyer_email || null,
|
||||||
|
data.supply_amount,
|
||||||
|
data.tax_amount,
|
||||||
|
data.total_amount,
|
||||||
|
data.invoice_date,
|
||||||
|
data.remarks || null,
|
||||||
|
data.order_id || null,
|
||||||
|
data.customer_id || null,
|
||||||
|
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||||
|
data.cost_type || null,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
|
||||||
|
// 품목 생성
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const item = data.items[i];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tax_invoice_item (
|
||||||
|
tax_invoice_id, company_code, item_seq,
|
||||||
|
item_date, item_name, item_spec, quantity, unit_price,
|
||||||
|
supply_amount, tax_amount, remarks
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
invoice.id,
|
||||||
|
companyCode,
|
||||||
|
i + 1,
|
||||||
|
item.item_date || null,
|
||||||
|
item.item_name,
|
||||||
|
item.item_spec || null,
|
||||||
|
item.quantity,
|
||||||
|
item.unit_price,
|
||||||
|
item.supply_amount,
|
||||||
|
item.tax_amount,
|
||||||
|
item.remarks || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 생성 완료", {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수정
|
||||||
|
*/
|
||||||
|
static async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<CreateTaxInvoiceDto>,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<TaxInvoice | null> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 기존 세금계산서 확인
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 발행된 세금계산서는 수정 불가
|
||||||
|
if (existing.rows[0].invoice_status !== "draft") {
|
||||||
|
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세금계산서 수정
|
||||||
|
const updateResult = await client.query(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
supplier_business_no = COALESCE($3, supplier_business_no),
|
||||||
|
supplier_name = COALESCE($4, supplier_name),
|
||||||
|
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
|
||||||
|
supplier_address = COALESCE($6, supplier_address),
|
||||||
|
supplier_business_type = COALESCE($7, supplier_business_type),
|
||||||
|
supplier_business_item = COALESCE($8, supplier_business_item),
|
||||||
|
buyer_business_no = COALESCE($9, buyer_business_no),
|
||||||
|
buyer_name = COALESCE($10, buyer_name),
|
||||||
|
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
|
||||||
|
buyer_address = COALESCE($12, buyer_address),
|
||||||
|
buyer_email = COALESCE($13, buyer_email),
|
||||||
|
supply_amount = COALESCE($14, supply_amount),
|
||||||
|
tax_amount = COALESCE($15, tax_amount),
|
||||||
|
total_amount = COALESCE($16, total_amount),
|
||||||
|
invoice_date = COALESCE($17, invoice_date),
|
||||||
|
remarks = COALESCE($18, remarks),
|
||||||
|
attachments = $19,
|
||||||
|
cost_type = COALESCE($20, cost_type),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
data.supplier_business_no,
|
||||||
|
data.supplier_name,
|
||||||
|
data.supplier_ceo_name,
|
||||||
|
data.supplier_address,
|
||||||
|
data.supplier_business_type,
|
||||||
|
data.supplier_business_item,
|
||||||
|
data.buyer_business_no,
|
||||||
|
data.buyer_name,
|
||||||
|
data.buyer_ceo_name,
|
||||||
|
data.buyer_address,
|
||||||
|
data.buyer_email,
|
||||||
|
data.supply_amount,
|
||||||
|
data.tax_amount,
|
||||||
|
data.total_amount,
|
||||||
|
data.invoice_date,
|
||||||
|
data.remarks,
|
||||||
|
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||||
|
data.cost_type,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||||
|
if (data.items) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const item = data.items[i];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tax_invoice_item (
|
||||||
|
tax_invoice_id, company_code, item_seq,
|
||||||
|
item_date, item_name, item_spec, quantity, unit_price,
|
||||||
|
supply_amount, tax_amount, remarks
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
i + 1,
|
||||||
|
item.item_date || null,
|
||||||
|
item.item_name,
|
||||||
|
item.item_spec || null,
|
||||||
|
item.quantity,
|
||||||
|
item.unit_price,
|
||||||
|
item.supply_amount,
|
||||||
|
item.tax_amount,
|
||||||
|
item.remarks || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return updateResult.rows[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 삭제
|
||||||
|
*/
|
||||||
|
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 기존 세금계산서 확인
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 발행된 세금계산서는 삭제 불가
|
||||||
|
if (existing.rows[0].invoice_status !== "draft") {
|
||||||
|
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 세금계산서 삭제
|
||||||
|
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 발행 (상태 변경)
|
||||||
|
*/
|
||||||
|
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
|
||||||
|
const result = await query<TaxInvoice>(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
invoice_status = 'issued',
|
||||||
|
issue_date = NOW(),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
|
||||||
|
RETURNING *`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 취소
|
||||||
|
*/
|
||||||
|
static async cancel(
|
||||||
|
id: string,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<TaxInvoice | null> {
|
||||||
|
const result = await query<TaxInvoice>(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
invoice_status = 'cancelled',
|
||||||
|
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
|
||||||
|
RETURNING *`,
|
||||||
|
[id, companyCode, reason || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
*/
|
||||||
|
static async getMonthlyStats(
|
||||||
|
companyCode: string,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<{
|
||||||
|
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
}> {
|
||||||
|
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||||
|
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
|
||||||
|
|
||||||
|
const result = await query<{
|
||||||
|
invoice_type: string;
|
||||||
|
count: string;
|
||||||
|
supply_amount: string;
|
||||||
|
tax_amount: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
invoice_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND invoice_date >= $2
|
||||||
|
AND invoice_date <= $3
|
||||||
|
AND invoice_status != 'cancelled'
|
||||||
|
GROUP BY invoice_type`,
|
||||||
|
[companyCode, startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||||
|
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const type = row.invoice_type as "sales" | "purchase";
|
||||||
|
stats[type] = {
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
supply_amount: parseFloat(row.supply_amount),
|
||||||
|
tax_amount: parseFloat(row.tax_amount),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 조회
|
||||||
|
*/
|
||||||
|
static async getCostTypeStats(
|
||||||
|
companyCode: string,
|
||||||
|
year?: number,
|
||||||
|
month?: number
|
||||||
|
): Promise<{
|
||||||
|
by_cost_type: Array<{
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
by_month: Array<{
|
||||||
|
year_month: string;
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
total_count: number;
|
||||||
|
total_amount: number;
|
||||||
|
purchase_amount: number;
|
||||||
|
installation_amount: number;
|
||||||
|
repair_amount: number;
|
||||||
|
maintenance_amount: number;
|
||||||
|
disposal_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
|
||||||
|
const values: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 연도/월 필터
|
||||||
|
if (year && month) {
|
||||||
|
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||||
|
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
|
||||||
|
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
|
||||||
|
values.push(startDate, endDate);
|
||||||
|
paramIndex += 2;
|
||||||
|
} else if (year) {
|
||||||
|
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
|
||||||
|
values.push(year);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// 비용 유형별 집계
|
||||||
|
const byCostType = await query<{
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: string;
|
||||||
|
supply_amount: string;
|
||||||
|
tax_amount: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
cost_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY cost_type
|
||||||
|
ORDER BY total_amount DESC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 월별 비용 유형 집계
|
||||||
|
const byMonth = await query<{
|
||||||
|
year_month: string;
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
|
||||||
|
cost_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
|
||||||
|
ORDER BY year_month DESC, cost_type`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 요약
|
||||||
|
const summaryResult = await query<{
|
||||||
|
total_count: string;
|
||||||
|
total_amount: string;
|
||||||
|
purchase_amount: string;
|
||||||
|
installation_amount: string;
|
||||||
|
repair_amount: string;
|
||||||
|
maintenance_amount: string;
|
||||||
|
disposal_amount: string;
|
||||||
|
other_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = summaryResult[0] || {
|
||||||
|
total_count: "0",
|
||||||
|
total_amount: "0",
|
||||||
|
purchase_amount: "0",
|
||||||
|
installation_amount: "0",
|
||||||
|
repair_amount: "0",
|
||||||
|
maintenance_amount: "0",
|
||||||
|
disposal_amount: "0",
|
||||||
|
other_amount: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
by_cost_type: byCostType.map((row) => ({
|
||||||
|
cost_type: row.cost_type,
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
supply_amount: parseFloat(row.supply_amount),
|
||||||
|
tax_amount: parseFloat(row.tax_amount),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
})),
|
||||||
|
by_month: byMonth.map((row) => ({
|
||||||
|
year_month: row.year_month,
|
||||||
|
cost_type: row.cost_type,
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
})),
|
||||||
|
summary: {
|
||||||
|
total_count: parseInt(summary.total_count, 10),
|
||||||
|
total_amount: parseFloat(summary.total_amount),
|
||||||
|
purchase_amount: parseFloat(summary.purchase_amount),
|
||||||
|
installation_amount: parseFloat(summary.installation_amount),
|
||||||
|
repair_amount: parseFloat(summary.repair_amount),
|
||||||
|
maintenance_amount: parseFloat(summary.maintenance_amount),
|
||||||
|
disposal_amount: parseFloat(summary.disposal_amount),
|
||||||
|
other_amount: parseFloat(summary.other_amount),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 대시보드
|
||||||
|
* 구매/설치/수리/유지보수/폐기 등 비용 정산 현황
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Package,
|
||||||
|
Wrench,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice";
|
||||||
|
|
||||||
|
// 비용 유형별 아이콘
|
||||||
|
const costTypeIcons: Record<CostType, React.ReactNode> = {
|
||||||
|
purchase: <Package className="h-4 w-4" />,
|
||||||
|
installation: <Settings className="h-4 w-4" />,
|
||||||
|
repair: <Wrench className="h-4 w-4" />,
|
||||||
|
maintenance: <Settings className="h-4 w-4" />,
|
||||||
|
disposal: <Trash2 className="h-4 w-4" />,
|
||||||
|
other: <DollarSign className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비용 유형별 색상
|
||||||
|
const costTypeColors: Record<CostType, string> = {
|
||||||
|
purchase: "bg-blue-500",
|
||||||
|
installation: "bg-green-500",
|
||||||
|
repair: "bg-orange-500",
|
||||||
|
maintenance: "bg-purple-500",
|
||||||
|
disposal: "bg-red-500",
|
||||||
|
other: "bg-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CostTypeStats() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<CostTypeStatsResponse["data"] | null>(null);
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// 연도 옵션 생성 (최근 5년)
|
||||||
|
const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||||
|
|
||||||
|
// 월 옵션 생성
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getCostTypeStats(selectedYear, selectedMonth);
|
||||||
|
if (response.success) {
|
||||||
|
setStats(response.data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("통계 로드 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedYear, selectedMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, [loadStats]);
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
if (amount >= 100000000) {
|
||||||
|
return `${(amount / 100000000).toFixed(1)}억`;
|
||||||
|
}
|
||||||
|
if (amount >= 10000) {
|
||||||
|
return `${(amount / 10000).toFixed(0)}만`;
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 금액 대비 비율 계산
|
||||||
|
const getPercentage = (amount: number) => {
|
||||||
|
if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0;
|
||||||
|
return (amount / stats.summary.total_amount) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">비용 정산 현황</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">구매/설치/수리/유지보수/폐기 비용 통계</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(selectedYear)}
|
||||||
|
onValueChange={(v) => setSelectedYear(parseInt(v, 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{yearOptions.map((year) => (
|
||||||
|
<SelectItem key={year} value={String(year)}>
|
||||||
|
{year}년
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={selectedMonth ? String(selectedMonth) : "all"}
|
||||||
|
onValueChange={(v) => setSelectedMonth(v === "all" ? undefined : parseInt(v, 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{monthOptions.map((month) => (
|
||||||
|
<SelectItem key={month} value={String(month)}>
|
||||||
|
{month}월
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={loadStats} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 비용</CardTitle>
|
||||||
|
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatAmount(stats?.summary.total_amount || 0)}원
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{stats?.summary.total_count || 0}건
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">구매 비용</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatAmount(stats?.summary.purchase_amount || 0)}원
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={getPercentage(stats?.summary.purchase_amount || 0)}
|
||||||
|
className="mt-2 h-1"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">수리/유지보수</CardTitle>
|
||||||
|
<Wrench className="h-4 w-4 text-orange-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={getPercentage((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
|
||||||
|
className="mt-2 h-1"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">설치/폐기</CardTitle>
|
||||||
|
<Settings className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={getPercentage((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
|
||||||
|
className="mt-2 h-1"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비용 유형별 상세 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>비용 유형별 상세</CardTitle>
|
||||||
|
<CardDescription>각 비용 유형별 금액 및 비율</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{stats?.by_cost_type && stats.by_cost_type.length > 0 ? (
|
||||||
|
stats.by_cost_type.map((item) => {
|
||||||
|
const costType = item.cost_type as CostType;
|
||||||
|
const percentage = getPercentage(item.total_amount);
|
||||||
|
return (
|
||||||
|
<div key={costType || "none"} className="flex items-center gap-4">
|
||||||
|
<div className="flex w-[120px] items-center gap-2">
|
||||||
|
{costType && costTypeIcons[costType]}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{costType ? costTypeLabels[costType] : "미분류"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${costType ? costTypeColors[costType] : "bg-gray-400"}`}
|
||||||
|
style={{ width: `${Math.max(percentage, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-[50px] text-right text-sm text-muted-foreground">
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-[120px] text-right">
|
||||||
|
<div className="font-mono text-sm font-semibold">
|
||||||
|
{formatAmount(item.total_amount)}원
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{item.count}건</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 월별 추이 */}
|
||||||
|
{!selectedMonth && stats?.by_month && stats.by_month.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>월별 비용 추이</CardTitle>
|
||||||
|
<CardDescription>{selectedYear}년 월별 비용 현황</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 월별 그룹핑 */}
|
||||||
|
{Array.from(new Set(stats.by_month.map((item) => item.year_month)))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((yearMonth) => {
|
||||||
|
const monthData = stats.by_month.filter((item) => item.year_month === yearMonth);
|
||||||
|
const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0);
|
||||||
|
const [year, month] = yearMonth.split("-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={yearMonth} className="flex items-center gap-4 py-2 border-b last:border-0">
|
||||||
|
<div className="w-[80px] text-sm font-medium">
|
||||||
|
{month}월
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex gap-1">
|
||||||
|
{monthData.map((item) => {
|
||||||
|
const costType = item.cost_type as CostType;
|
||||||
|
const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={costType || "none"}
|
||||||
|
className={`h-6 ${costType ? costTypeColors[costType] : "bg-gray-400"} rounded`}
|
||||||
|
style={{ width: `${Math.max(width, 5)}%` }}
|
||||||
|
title={`${costType ? costTypeLabels[costType] : "미분류"}: ${formatAmount(item.total_amount)}원`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="w-[100px] text-right font-mono text-sm">
|
||||||
|
{formatAmount(monthTotal)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3 pt-4 border-t">
|
||||||
|
{Object.entries(costTypeLabels).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<div className={`w-3 h-3 rounded ${costTypeColors[key as CostType]}`} />
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,621 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 보기 컴포넌트
|
||||||
|
* PDF 출력 및 첨부파일 다운로드 기능 포함
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
Printer,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTaxInvoiceById,
|
||||||
|
TaxInvoice,
|
||||||
|
TaxInvoiceItem,
|
||||||
|
TaxInvoiceAttachment,
|
||||||
|
} from "@/lib/api/taxInvoice";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface TaxInvoiceDetailProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
invoiceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 라벨
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
draft: "임시저장",
|
||||||
|
issued: "발행완료",
|
||||||
|
sent: "전송완료",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 색상
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: "bg-gray-100 text-gray-800",
|
||||||
|
issued: "bg-green-100 text-green-800",
|
||||||
|
sent: "bg-blue-100 text-blue-800",
|
||||||
|
cancelled: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
|
||||||
|
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
|
||||||
|
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && invoiceId) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [open, invoiceId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getTaxInvoiceById(invoiceId);
|
||||||
|
if (response.success) {
|
||||||
|
setInvoice(response.data.invoice);
|
||||||
|
setItems(response.data.items);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("데이터 로드 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
|
||||||
|
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
|
||||||
|
// objid가 숫자형이면 API를 통해 미리보기
|
||||||
|
if (attachment.id && !attachment.id.includes("-")) {
|
||||||
|
// apiClient의 baseURL 사용
|
||||||
|
const baseURL = apiClient.defaults.baseURL || "";
|
||||||
|
return `${baseURL}/files/preview/${attachment.id}`;
|
||||||
|
}
|
||||||
|
return attachment.file_path;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공통 인쇄용 HTML 생성 함수
|
||||||
|
const generatePrintHtml = (autoPrint: boolean = false) => {
|
||||||
|
if (!invoice) return "";
|
||||||
|
|
||||||
|
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
|
||||||
|
const itemsHtml = items.map((item, index) => `
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">${index + 1}</td>
|
||||||
|
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
|
||||||
|
<td>${item.item_name}</td>
|
||||||
|
<td>${item.item_spec || "-"}</td>
|
||||||
|
<td style="text-align:right">${item.quantity}</td>
|
||||||
|
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
|
||||||
|
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
|
||||||
|
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>세금계산서_${invoice.invoice_number}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; }
|
||||||
|
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
|
||||||
|
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
||||||
|
.header .invoice-number { font-size: 14px; color: #666; }
|
||||||
|
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
|
||||||
|
.status-draft { background: #f3f4f6; color: #374151; }
|
||||||
|
.status-issued { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-sent { background: #dbeafe; color: #1e40af; }
|
||||||
|
.status-cancelled { background: #fee2e2; color: #991b1b; }
|
||||||
|
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
|
||||||
|
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||||
|
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||||
|
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||||
|
.party-label { width: 80px; color: #666; }
|
||||||
|
.party-value { flex: 1; }
|
||||||
|
.items-section { margin-bottom: 30px; }
|
||||||
|
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; }
|
||||||
|
th { background: #f9fafb; font-weight: 600; }
|
||||||
|
.total-section { display: flex; justify-content: flex-end; }
|
||||||
|
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
|
||||||
|
.total-row.grand .value { color: #1d4ed8; }
|
||||||
|
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
|
||||||
|
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
|
||||||
|
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
|
||||||
|
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||||
|
.attachments ul { list-style: none; font-size: 12px; }
|
||||||
|
.attachments li { padding: 4px 0; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 15px; }
|
||||||
|
.no-print { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>세금계산서 (${invoiceTypeText})</h1>
|
||||||
|
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
|
||||||
|
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="parties">
|
||||||
|
<div class="party">
|
||||||
|
<h3>공급자</h3>
|
||||||
|
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">업태/종목</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="party">
|
||||||
|
<h3>공급받는자</h3>
|
||||||
|
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">이메일</span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
|
||||||
|
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-section">
|
||||||
|
<h3>품목 내역</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px">No</th>
|
||||||
|
<th style="width:80px">일자</th>
|
||||||
|
<th>품목명</th>
|
||||||
|
<th style="width:70px">규격</th>
|
||||||
|
<th style="width:50px">수량</th>
|
||||||
|
<th style="width:80px">단가</th>
|
||||||
|
<th style="width:90px">공급가액</th>
|
||||||
|
<th style="width:70px">세액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-section">
|
||||||
|
<div class="total-box">
|
||||||
|
<div class="total-row"><span>공급가액</span><span>${formatAmount(invoice.supply_amount)}원</span></div>
|
||||||
|
<div class="total-row"><span>세액</span><span>${formatAmount(invoice.tax_amount)}원</span></div>
|
||||||
|
<div class="total-row grand"><span>합계금액</span><span class="value">${formatAmount(invoice.total_amount)}원</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
|
||||||
|
|
||||||
|
${invoice.attachments && invoice.attachments.length > 0 ? `
|
||||||
|
<div class="attachments">
|
||||||
|
<h3>첨부파일 (${invoice.attachments.length}개)</h3>
|
||||||
|
<ul>
|
||||||
|
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
|
||||||
|
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인쇄
|
||||||
|
const handlePrint = () => {
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) {
|
||||||
|
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(generatePrintHtml(true));
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// PDF 다운로드 (인쇄 다이얼로그 사용)
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
setPdfLoading(true);
|
||||||
|
try {
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) {
|
||||||
|
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(generatePrintHtml(true));
|
||||||
|
printWindow.document.close();
|
||||||
|
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("PDF 생성 오류:", error);
|
||||||
|
toast.error("PDF 생성 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 아이콘
|
||||||
|
const getFileIcon = (fileType: string) => {
|
||||||
|
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||||
|
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||||
|
return <File className="h-4 w-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 다운로드 (인증 토큰 포함)
|
||||||
|
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
|
||||||
|
try {
|
||||||
|
// objid가 숫자형이면 API를 통해 다운로드
|
||||||
|
if (attachment.id && !attachment.id.includes("-")) {
|
||||||
|
const response = await apiClient.get(`/files/download/${attachment.id}`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blob으로 다운로드
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = attachment.file_name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
// 직접 경로로 다운로드
|
||||||
|
window.open(attachment.file_path, "_blank");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("파일 다운로드 실패", { description: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex h-[400px] items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
|
||||||
|
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
|
||||||
|
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="mr-1 h-4 w-4" />
|
||||||
|
인쇄
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={pdfLoading}
|
||||||
|
>
|
||||||
|
{pdfLoading ? (
|
||||||
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
PDF 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[calc(90vh-120px)]">
|
||||||
|
<div className="p-6" ref={printRef}>
|
||||||
|
<div className="invoice-container">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold">
|
||||||
|
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
계산서번호: {invoice.invoice_number}
|
||||||
|
</p>
|
||||||
|
<Badge className={statusColors[invoice.invoice_status]}>
|
||||||
|
{statusLabels[invoice.invoice_status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급자 / 공급받는자 정보 */}
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-6">
|
||||||
|
{/* 공급자 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="mb-3 border-b pb-2 font-semibold">공급자</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||||
|
<span>{invoice.supplier_business_no || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">상호</span>
|
||||||
|
<span>{invoice.supplier_name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">대표자</span>
|
||||||
|
<span>{invoice.supplier_ceo_name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">업태/종목</span>
|
||||||
|
<span>
|
||||||
|
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">주소</span>
|
||||||
|
<span className="flex-1">{invoice.supplier_address || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급받는자 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="mb-3 border-b pb-2 font-semibold">공급받는자</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||||
|
<span>{invoice.buyer_business_no || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">상호</span>
|
||||||
|
<span>{invoice.buyer_name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">대표자</span>
|
||||||
|
<span>{invoice.buyer_ceo_name || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">이메일</span>
|
||||||
|
<span>{invoice.buyer_email || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground w-24">주소</span>
|
||||||
|
<span className="flex-1">{invoice.buyer_address || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 내역 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold">품목 내역</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">No</TableHead>
|
||||||
|
<TableHead className="w-[100px]">일자</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
|
||||||
|
<TableCell>{item.item_name}</TableCell>
|
||||||
|
<TableCell>{item.item_spec || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(item.unit_price)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(item.supply_amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(item.tax_amount)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
|
||||||
|
품목 내역이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">공급가액</span>
|
||||||
|
<span className="font-mono">{formatAmount(invoice.supply_amount)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">세액</span>
|
||||||
|
<span className="font-mono">{formatAmount(invoice.tax_amount)}원</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>합계금액</span>
|
||||||
|
<span className="font-mono text-primary">
|
||||||
|
{formatAmount(invoice.total_amount)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
{invoice.remarks && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="mb-2 font-semibold">비고</h3>
|
||||||
|
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
|
||||||
|
{invoice.remarks}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 날짜 정보 */}
|
||||||
|
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
|
||||||
|
<span>작성일: {formatDate(invoice.invoice_date)}</span>
|
||||||
|
{invoice.issue_date && <span>발행일: {formatDate(invoice.issue_date)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 */}
|
||||||
|
{invoice.attachments && invoice.attachments.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Separator className="mb-4" />
|
||||||
|
<h3 className="mb-3 font-semibold">첨부파일 ({invoice.attachments.length}개)</h3>
|
||||||
|
|
||||||
|
{/* 이미지 미리보기 */}
|
||||||
|
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{invoice.attachments
|
||||||
|
.filter((f) => f.file_type?.startsWith("image/"))
|
||||||
|
.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getFilePreviewUrl(file)}
|
||||||
|
alt={file.file_name}
|
||||||
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<div className="w-full p-2">
|
||||||
|
<p className="truncate text-xs text-white">{file.file_name}</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 h-7 w-full text-xs"
|
||||||
|
onClick={() => handleDownload(file)}
|
||||||
|
>
|
||||||
|
<Download className="mr-1 h-3 w-3" />
|
||||||
|
다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기타 파일 목록 */}
|
||||||
|
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invoice.attachments
|
||||||
|
.filter((f) => !f.file_type?.startsWith("image/"))
|
||||||
|
.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getFileIcon(file.file_type)}
|
||||||
|
<span className="text-sm">{file.file_name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(file)}
|
||||||
|
>
|
||||||
|
<Download className="mr-1 h-4 w-4" />
|
||||||
|
다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,728 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 작성/수정 폼
|
||||||
|
* 파일 첨부 기능 포함
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
Paperclip,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTaxInvoice,
|
||||||
|
updateTaxInvoice,
|
||||||
|
getTaxInvoiceById,
|
||||||
|
TaxInvoice,
|
||||||
|
TaxInvoiceAttachment,
|
||||||
|
CreateTaxInvoiceDto,
|
||||||
|
CreateTaxInvoiceItemDto,
|
||||||
|
CostType,
|
||||||
|
costTypeLabels,
|
||||||
|
} from "@/lib/api/taxInvoice";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface TaxInvoiceFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
invoice?: TaxInvoice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목 초기값
|
||||||
|
const emptyItem: CreateTaxInvoiceItemDto = {
|
||||||
|
item_date: format(new Date(), "yyyy-MM-dd"),
|
||||||
|
item_name: "",
|
||||||
|
item_spec: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
supply_amount: 0,
|
||||||
|
tax_amount: 0,
|
||||||
|
remarks: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) {
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState<CreateTaxInvoiceDto>({
|
||||||
|
invoice_type: "sales",
|
||||||
|
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||||
|
supply_amount: 0,
|
||||||
|
tax_amount: 0,
|
||||||
|
total_amount: 0,
|
||||||
|
items: [{ ...emptyItem }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 첨부파일 상태
|
||||||
|
const [attachments, setAttachments] = useState<TaxInvoiceAttachment[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("basic");
|
||||||
|
|
||||||
|
// 수정 모드일 때 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoice) {
|
||||||
|
loadInvoiceData(invoice.id);
|
||||||
|
} else {
|
||||||
|
// 새 세금계산서
|
||||||
|
setFormData({
|
||||||
|
invoice_type: "sales",
|
||||||
|
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||||
|
supply_amount: 0,
|
||||||
|
tax_amount: 0,
|
||||||
|
total_amount: 0,
|
||||||
|
items: [{ ...emptyItem }],
|
||||||
|
});
|
||||||
|
setAttachments([]);
|
||||||
|
}
|
||||||
|
}, [invoice]);
|
||||||
|
|
||||||
|
// 세금계산서 데이터 로드
|
||||||
|
const loadInvoiceData = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await getTaxInvoiceById(id);
|
||||||
|
if (response.success) {
|
||||||
|
const { invoice: inv, items } = response.data;
|
||||||
|
setFormData({
|
||||||
|
invoice_type: inv.invoice_type,
|
||||||
|
invoice_date: inv.invoice_date?.split("T")[0] || "",
|
||||||
|
supplier_business_no: inv.supplier_business_no,
|
||||||
|
supplier_name: inv.supplier_name,
|
||||||
|
supplier_ceo_name: inv.supplier_ceo_name,
|
||||||
|
supplier_address: inv.supplier_address,
|
||||||
|
supplier_business_type: inv.supplier_business_type,
|
||||||
|
supplier_business_item: inv.supplier_business_item,
|
||||||
|
buyer_business_no: inv.buyer_business_no,
|
||||||
|
buyer_name: inv.buyer_name,
|
||||||
|
buyer_ceo_name: inv.buyer_ceo_name,
|
||||||
|
buyer_address: inv.buyer_address,
|
||||||
|
buyer_email: inv.buyer_email,
|
||||||
|
supply_amount: inv.supply_amount,
|
||||||
|
tax_amount: inv.tax_amount,
|
||||||
|
total_amount: inv.total_amount,
|
||||||
|
remarks: inv.remarks,
|
||||||
|
cost_type: inv.cost_type || undefined,
|
||||||
|
items:
|
||||||
|
items.length > 0
|
||||||
|
? items.map((item) => ({
|
||||||
|
item_date: item.item_date?.split("T")[0] || "",
|
||||||
|
item_name: item.item_name,
|
||||||
|
item_spec: item.item_spec,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
supply_amount: item.supply_amount,
|
||||||
|
tax_amount: item.tax_amount,
|
||||||
|
remarks: item.remarks,
|
||||||
|
}))
|
||||||
|
: [{ ...emptyItem }],
|
||||||
|
});
|
||||||
|
setAttachments(inv.attachments || []);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("데이터 로드 실패", { description: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 변경
|
||||||
|
const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 변경
|
||||||
|
const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const items = [...(prev.items || [])];
|
||||||
|
items[index] = { ...items[index], [field]: value };
|
||||||
|
|
||||||
|
// 공급가액 자동 계산
|
||||||
|
if (field === "quantity" || field === "unit_price") {
|
||||||
|
const qty = field === "quantity" ? value : items[index].quantity;
|
||||||
|
const price = field === "unit_price" ? value : items[index].unit_price;
|
||||||
|
items[index].supply_amount = qty * price;
|
||||||
|
items[index].tax_amount = Math.round(items[index].supply_amount * 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총액 재계산
|
||||||
|
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||||
|
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items,
|
||||||
|
supply_amount: totalSupply,
|
||||||
|
tax_amount: totalTax,
|
||||||
|
total_amount: totalSupply + totalTax,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 추가
|
||||||
|
const handleAddItem = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
items: [...(prev.items || []), { ...emptyItem }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 삭제
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const items = (prev.items || []).filter((_, i) => i !== index);
|
||||||
|
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||||
|
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: items.length > 0 ? items : [{ ...emptyItem }],
|
||||||
|
supply_amount: totalSupply,
|
||||||
|
tax_amount: totalTax,
|
||||||
|
total_amount: totalSupply + totalTax,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 업로드
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const formDataUpload = new FormData();
|
||||||
|
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
|
||||||
|
formDataUpload.append("category", "tax-invoice");
|
||||||
|
|
||||||
|
const response = await apiClient.post("/files/upload", formDataUpload, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success && response.data.files?.length > 0) {
|
||||||
|
const uploadedFile = response.data.files[0];
|
||||||
|
const newAttachment: TaxInvoiceAttachment = {
|
||||||
|
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
file_name: uploadedFile.realFileName || file.name,
|
||||||
|
file_path: uploadedFile.filePath,
|
||||||
|
file_size: uploadedFile.fileSize || file.size,
|
||||||
|
file_type: file.type,
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
uploaded_by: "",
|
||||||
|
};
|
||||||
|
setAttachments((prev) => [...prev, newAttachment]);
|
||||||
|
toast.success(`'${file.name}' 업로드 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("파일 업로드 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
// input 초기화
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 삭제
|
||||||
|
const handleRemoveAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 아이콘
|
||||||
|
const getFileIcon = (fileType: string) => {
|
||||||
|
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||||
|
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||||
|
return <File className="h-4 w-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 크기 포맷
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.invoice_date) {
|
||||||
|
toast.error("작성일자를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (invoice) {
|
||||||
|
response = await updateTaxInvoice(invoice.id, dataToSave);
|
||||||
|
} else {
|
||||||
|
response = await createTaxInvoice(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message || "저장되었습니다.");
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("저장 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[900px] overflow-hidden p-0">
|
||||||
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
|
<DialogTitle>{invoice ? "세금계산서 수정" : "세금계산서 작성"}</DialogTitle>
|
||||||
|
<DialogDescription>세금계산서 정보를 입력해주세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[calc(90vh-180px)]">
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="basic">기본정보</TabsTrigger>
|
||||||
|
<TabsTrigger value="supplier">공급자</TabsTrigger>
|
||||||
|
<TabsTrigger value="buyer">공급받는자</TabsTrigger>
|
||||||
|
<TabsTrigger value="attachments">
|
||||||
|
첨부파일
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{attachments.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 기본정보 탭 */}
|
||||||
|
<TabsContent value="basic" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">유형 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.invoice_type}
|
||||||
|
onValueChange={(v) => handleChange("invoice_type", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sales">매출</SelectItem>
|
||||||
|
<SelectItem value="purchase">매입</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">비용 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cost_type || "none"}
|
||||||
|
onValueChange={(v) => handleChange("cost_type", v === "none" ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="선택 안함" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안함</SelectItem>
|
||||||
|
{Object.entries(costTypeLabels).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">작성일자 *</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.invoice_date}
|
||||||
|
onChange={(e) => handleChange("invoice_date", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">비고</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.remarks || ""}
|
||||||
|
onChange={(e) => handleChange("remarks", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="비고 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 테이블 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm">품목 내역</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
품목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">일자</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(formData.items || []).map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={item.item_date || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(index, "item_date", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.item_name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(index, "item_name", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="품목명"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.item_spec || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(index, "item_spec", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="규격"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
className="h-8 text-right text-xs"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.unit_price || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
index,
|
||||||
|
"unit_price",
|
||||||
|
parseFloat(e.target.value) || 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 text-right text-xs"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-right font-mono text-xs">
|
||||||
|
{formatAmount(item.supply_amount || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-right font-mono text-xs">
|
||||||
|
{formatAmount(item.tax_amount || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleRemoveItem(index)}
|
||||||
|
disabled={(formData.items?.length || 0) <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">공급가액</span>
|
||||||
|
<span className="font-mono">{formatAmount(formData.supply_amount || 0)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">세액</span>
|
||||||
|
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t pt-2 text-lg font-bold">
|
||||||
|
<span>합계</span>
|
||||||
|
<span className="font-mono text-primary">
|
||||||
|
{formatAmount(formData.total_amount || 0)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 공급자 탭 */}
|
||||||
|
<TabsContent value="supplier" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사업자등록번호</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_business_no || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="000-00-00000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상호</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_name || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_name", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="상호명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">대표자명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_ceo_name || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="대표자명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">업태</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_business_type || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="업태"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">종목</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_business_item || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="종목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-xs">주소</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.supplier_address || ""}
|
||||||
|
onChange={(e) => handleChange("supplier_address", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="주소"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 공급받는자 탭 */}
|
||||||
|
<TabsContent value="buyer" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사업자등록번호</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.buyer_business_no || ""}
|
||||||
|
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="000-00-00000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상호</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.buyer_name || ""}
|
||||||
|
onChange={(e) => handleChange("buyer_name", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="상호명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">대표자명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.buyer_ceo_name || ""}
|
||||||
|
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="대표자명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">이메일</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.buyer_email || ""}
|
||||||
|
onChange={(e) => handleChange("buyer_email", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-xs">주소</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.buyer_address || ""}
|
||||||
|
onChange={(e) => handleChange("buyer_address", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
placeholder="주소"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 첨부파일 탭 */}
|
||||||
|
<TabsContent value="attachments" className="space-y-4">
|
||||||
|
{/* 업로드 영역 */}
|
||||||
|
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="flex cursor-pointer flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="text-muted-foreground h-8 w-8" />
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
PDF, 이미지, 문서 파일 (최대 10MB)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 목록 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">첨부된 파일 ({attachments.length}개)</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getFileIcon(file.file_type)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{file.file_name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatFileSize(file.file_size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleRemoveAttachment(file.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments.length === 0 && (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||||
|
첨부된 파일이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,832 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 컴포넌트
|
||||||
|
* 세금계산서 목록 조회, 검색, 필터링 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
FileText,
|
||||||
|
Eye,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Paperclip,
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTaxInvoiceList,
|
||||||
|
deleteTaxInvoice,
|
||||||
|
issueTaxInvoice,
|
||||||
|
cancelTaxInvoice,
|
||||||
|
TaxInvoice,
|
||||||
|
TaxInvoiceListParams,
|
||||||
|
CostType,
|
||||||
|
costTypeLabels,
|
||||||
|
} from "@/lib/api/taxInvoice";
|
||||||
|
import { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||||
|
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||||
|
|
||||||
|
// 상태 뱃지 색상
|
||||||
|
const statusBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
draft: "outline",
|
||||||
|
issued: "default",
|
||||||
|
sent: "secondary",
|
||||||
|
cancelled: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 라벨
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
draft: "임시저장",
|
||||||
|
issued: "발행완료",
|
||||||
|
sent: "전송완료",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유형 라벨
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
sales: "매출",
|
||||||
|
purchase: "매입",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 정의
|
||||||
|
interface ColumnDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
filterType?: "text" | "select";
|
||||||
|
filterOptions?: { value: string; label: string }[];
|
||||||
|
width?: string;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef[] = [
|
||||||
|
{ key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" },
|
||||||
|
{ key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select",
|
||||||
|
filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "70px" },
|
||||||
|
{ key: "cost_type", label: "비용유형", sortable: true, filterable: true, filterType: "select",
|
||||||
|
filterOptions: Object.entries(costTypeLabels).map(([value, label]) => ({ value, label })), width: "90px" },
|
||||||
|
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
|
||||||
|
filterOptions: [
|
||||||
|
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
|
||||||
|
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
|
||||||
|
], width: "90px" },
|
||||||
|
{ key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" },
|
||||||
|
{ key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" },
|
||||||
|
{ key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" },
|
||||||
|
{ key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" },
|
||||||
|
{ key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" },
|
||||||
|
{ key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TaxInvoiceList() {
|
||||||
|
// 상태
|
||||||
|
const [invoices, setInvoices] = useState<TaxInvoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [filters, setFilters] = useState<TaxInvoiceListParams>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 정렬 상태
|
||||||
|
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||||
|
|
||||||
|
// 컬럼별 필터 상태
|
||||||
|
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
|
||||||
|
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState<TaxInvoice | null>(null);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
// 확인 다이얼로그 상태
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
type: "delete" | "issue" | "cancel";
|
||||||
|
invoice: TaxInvoice | null;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
type: "delete",
|
||||||
|
invoice: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 컬럼 필터를 API 파라미터에 추가
|
||||||
|
const apiFilters: TaxInvoiceListParams = {
|
||||||
|
...filters,
|
||||||
|
invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined,
|
||||||
|
invoice_status: columnFilters.invoice_status,
|
||||||
|
cost_type: columnFilters.cost_type as CostType | undefined,
|
||||||
|
search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await getTaxInvoiceList(apiFilters);
|
||||||
|
if (response.success) {
|
||||||
|
let data = response.data;
|
||||||
|
|
||||||
|
// 클라이언트 사이드 정렬 적용
|
||||||
|
if (sortConfig) {
|
||||||
|
data = [...data].sort((a, b) => {
|
||||||
|
const aVal = a[sortConfig.key as keyof TaxInvoice];
|
||||||
|
const bVal = b[sortConfig.key as keyof TaxInvoice];
|
||||||
|
|
||||||
|
if (aVal === null || aVal === undefined) return 1;
|
||||||
|
if (bVal === null || bVal === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자 비교
|
||||||
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||||
|
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열 비교
|
||||||
|
const strA = String(aVal).toLowerCase();
|
||||||
|
const strB = String(bVal).toLowerCase();
|
||||||
|
if (sortConfig.direction === "asc") {
|
||||||
|
return strA.localeCompare(strB, "ko");
|
||||||
|
}
|
||||||
|
return strB.localeCompare(strA, "ko");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 클라이언트 사이드 필터 적용 (날짜 필터)
|
||||||
|
if (columnFilters.invoice_date) {
|
||||||
|
data = data.filter((item) =>
|
||||||
|
item.invoice_date?.includes(columnFilters.invoice_date)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvoices(data);
|
||||||
|
setPagination(response.pagination);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("데이터 로드 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, sortConfig, columnFilters, searchText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 정렬 핸들러
|
||||||
|
const handleSort = (columnKey: string) => {
|
||||||
|
setSortConfig((prev) => {
|
||||||
|
if (prev?.key === columnKey) {
|
||||||
|
// 같은 컬럼 클릭: asc -> desc -> null 순환
|
||||||
|
if (prev.direction === "asc") return { key: columnKey, direction: "desc" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 새 컬럼: asc로 시작
|
||||||
|
return { key: columnKey, direction: "asc" };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 필터 핸들러
|
||||||
|
const handleColumnFilter = (columnKey: string, value: string) => {
|
||||||
|
setColumnFilters((prev) => {
|
||||||
|
if (!value) {
|
||||||
|
const { [columnKey]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return { ...prev, [columnKey]: value };
|
||||||
|
});
|
||||||
|
setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const clearColumnFilter = (columnKey: string) => {
|
||||||
|
setColumnFilters((prev) => {
|
||||||
|
const { [columnKey]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
setActiveFilterColumn(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 필터 초기화
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
setColumnFilters({});
|
||||||
|
setSortConfig(null);
|
||||||
|
setSearchText("");
|
||||||
|
setFilters({ page: 1, pageSize: 20 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 아이콘 렌더링
|
||||||
|
const renderSortIcon = (columnKey: string) => {
|
||||||
|
if (sortConfig?.key !== columnKey) {
|
||||||
|
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-30" />;
|
||||||
|
}
|
||||||
|
return sortConfig.direction === "asc"
|
||||||
|
? <ArrowUp className="ml-1 h-3 w-3 text-primary" />
|
||||||
|
: <ArrowDown className="ml-1 h-3 w-3 text-primary" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters((prev) => ({ ...prev, search: searchText, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 변경
|
||||||
|
const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value === "all" ? undefined : value,
|
||||||
|
page: 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 새 세금계산서
|
||||||
|
const handleNew = () => {
|
||||||
|
setSelectedInvoice(null);
|
||||||
|
setEditMode(false);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상세 보기
|
||||||
|
const handleView = (invoice: TaxInvoice) => {
|
||||||
|
setSelectedInvoice(invoice);
|
||||||
|
setShowDetail(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
const handleEdit = (invoice: TaxInvoice) => {
|
||||||
|
if (invoice.invoice_status !== "draft") {
|
||||||
|
toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedInvoice(invoice);
|
||||||
|
setEditMode(true);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (invoice: TaxInvoice) => {
|
||||||
|
if (invoice.invoice_status !== "draft") {
|
||||||
|
toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmDialog({ open: true, type: "delete", invoice });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 발행 확인
|
||||||
|
const handleIssueConfirm = (invoice: TaxInvoice) => {
|
||||||
|
if (invoice.invoice_status !== "draft") {
|
||||||
|
toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmDialog({ open: true, type: "issue", invoice });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소 확인
|
||||||
|
const handleCancelConfirm = (invoice: TaxInvoice) => {
|
||||||
|
if (!["draft", "issued"].includes(invoice.invoice_status)) {
|
||||||
|
toast.warning("취소할 수 없는 상태입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmDialog({ open: true, type: "cancel", invoice });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 확인 다이얼로그 실행
|
||||||
|
const handleConfirmAction = async () => {
|
||||||
|
const { type, invoice } = confirmDialog;
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === "delete") {
|
||||||
|
const response = await deleteTaxInvoice(invoice.id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("세금계산서가 삭제되었습니다.");
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
} else if (type === "issue") {
|
||||||
|
const response = await issueTaxInvoice(invoice.id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("세금계산서가 발행되었습니다.");
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
} else if (type === "cancel") {
|
||||||
|
const response = await cancelTaxInvoice(invoice.id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("세금계산서가 취소되었습니다.");
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("작업 실패", { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setConfirmDialog({ open: false, type: "delete", invoice: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 저장 완료
|
||||||
|
const handleFormSave = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setSelectedInvoice(null);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), "yyyy-MM-dd", { locale: ko });
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">세금계산서 관리</h1>
|
||||||
|
<Button onClick={handleNew}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 세금계산서
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="min-w-[200px] flex-1">
|
||||||
|
<Label className="text-xs">검색</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="계산서번호, 거래처명 검색"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유형 필터 */}
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<Label className="text-xs">유형</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.invoice_type || "all"}
|
||||||
|
onValueChange={(v) => handleFilterChange("invoice_type", v as "sales" | "purchase")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="sales">매출</SelectItem>
|
||||||
|
<SelectItem value="purchase">매입</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.invoice_status || "all"}
|
||||||
|
onValueChange={(v) => handleFilterChange("invoice_status", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="draft">임시저장</SelectItem>
|
||||||
|
<SelectItem value="issued">발행완료</SelectItem>
|
||||||
|
<SelectItem value="sent">전송완료</SelectItem>
|
||||||
|
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
|
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 필터 초기화 */}
|
||||||
|
{(Object.keys(columnFilters).length > 0 || sortConfig) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="text-muted-foreground">
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
필터 초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성 필터 표시 */}
|
||||||
|
{Object.keys(columnFilters).length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{Object.entries(columnFilters).map(([key, value]) => {
|
||||||
|
const column = columns.find((c) => c.key === key);
|
||||||
|
let displayValue = value;
|
||||||
|
if (column?.filterOptions) {
|
||||||
|
displayValue = column.filterOptions.find((o) => o.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge key={key} variant="secondary" className="gap-1 pr-1">
|
||||||
|
{column?.label}: {displayValue}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
|
onClick={() => clearColumnFilter(key)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={column.key}
|
||||||
|
className={`
|
||||||
|
${column.width ? `w-[${column.width}]` : ""}
|
||||||
|
${column.align === "center" ? "text-center" : ""}
|
||||||
|
${column.align === "right" ? "text-right" : ""}
|
||||||
|
`}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-1 ${column.align === "right" ? "justify-end" : column.align === "center" ? "justify-center" : ""}`}>
|
||||||
|
{/* 컬럼 필터 (filterable인 경우) */}
|
||||||
|
{column.filterable && (
|
||||||
|
<Popover
|
||||||
|
open={activeFilterColumn === column.key}
|
||||||
|
onOpenChange={(open) => setActiveFilterColumn(open ? column.key : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-6 w-6 p-0 ${columnFilters[column.key] ? "text-primary" : "text-muted-foreground opacity-50 hover:opacity-100"}`}
|
||||||
|
>
|
||||||
|
<Filter className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-56 p-2" align="start">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">{column.label} 필터</div>
|
||||||
|
{column.filterType === "select" ? (
|
||||||
|
<Select
|
||||||
|
value={columnFilters[column.key] || ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
handleColumnFilter(column.key, v);
|
||||||
|
setActiveFilterColumn(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{column.filterOptions?.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
placeholder={`${column.label} 검색...`}
|
||||||
|
value={columnFilters[column.key] || ""}
|
||||||
|
onChange={(e) => handleColumnFilter(column.key, e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columnFilters[column.key] && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
onClick={() => clearColumnFilter(column.key)}
|
||||||
|
>
|
||||||
|
필터 지우기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 라벨 + 정렬 */}
|
||||||
|
{column.sortable ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 font-medium hover:bg-transparent"
|
||||||
|
onClick={() => handleSort(column.key)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{renderSortIcon(column.key)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span>{column.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||||
|
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||||
|
세금계산서가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
invoices.map((invoice) => (
|
||||||
|
<TableRow key={invoice.id} className="cursor-pointer hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-sm">{invoice.invoice_number}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={invoice.invoice_type === "sales" ? "default" : "secondary"}>
|
||||||
|
{typeLabels[invoice.invoice_type]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{invoice.cost_type ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{costTypeLabels[invoice.cost_type as CostType]}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
|
||||||
|
{statusLabels[invoice.invoice_status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{invoice.buyer_name || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{invoice.attachments && invoice.attachments.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{invoice.attachments.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(invoice.supply_amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(invoice.tax_amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-semibold">
|
||||||
|
{formatAmount(invoice.total_amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleView(invoice)}
|
||||||
|
title="상세보기"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{invoice.invoice_status === "draft" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEdit(invoice)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleIssueConfirm(invoice)}
|
||||||
|
title="발행"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => handleDeleteConfirm(invoice)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{invoice.invoice_status === "issued" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-orange-600"
|
||||||
|
onClick={() => handleCancelConfirm(invoice)}
|
||||||
|
title="취소"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}-
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.total)}건
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 세금계산서 작성/수정 폼 */}
|
||||||
|
{showForm && (
|
||||||
|
<TaxInvoiceForm
|
||||||
|
open={showForm}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
onSave={handleFormSave}
|
||||||
|
invoice={editMode ? selectedInvoice : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 세금계산서 상세 */}
|
||||||
|
{showDetail && selectedInvoice && (
|
||||||
|
<TaxInvoiceDetail
|
||||||
|
open={showDetail}
|
||||||
|
onClose={() => setShowDetail(false)}
|
||||||
|
invoiceId={selectedInvoice.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 확인 다이얼로그 */}
|
||||||
|
<Dialog
|
||||||
|
open={confirmDialog.open}
|
||||||
|
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{confirmDialog.type === "delete" && "세금계산서 삭제"}
|
||||||
|
{confirmDialog.type === "issue" && "세금계산서 발행"}
|
||||||
|
{confirmDialog.type === "cancel" && "세금계산서 취소"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{confirmDialog.type === "delete" &&
|
||||||
|
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
|
||||||
|
{confirmDialog.type === "issue" &&
|
||||||
|
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
|
||||||
|
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
|
||||||
|
onClick={handleConfirmAction}
|
||||||
|
>
|
||||||
|
{confirmDialog.type === "delete" && "삭제"}
|
||||||
|
{confirmDialog.type === "issue" && "발행"}
|
||||||
|
{confirmDialog.type === "cancel" && "취소 처리"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { TaxInvoiceList } from "./TaxInvoiceList";
|
||||||
|
export { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||||
|
export { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// 비용 유형
|
||||||
|
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||||
|
|
||||||
|
// 비용 유형 라벨
|
||||||
|
export const costTypeLabels: Record<CostType, string> = {
|
||||||
|
purchase: "구매",
|
||||||
|
installation: "설치",
|
||||||
|
repair: "수리",
|
||||||
|
maintenance: "유지보수",
|
||||||
|
disposal: "폐기",
|
||||||
|
other: "기타",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 세금계산서 타입
|
||||||
|
export interface TaxInvoice {
|
||||||
|
id: string;
|
||||||
|
company_code: string;
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_type: "sales" | "purchase";
|
||||||
|
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||||
|
supplier_business_no: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_ceo_name: string;
|
||||||
|
supplier_address: string;
|
||||||
|
supplier_business_type: string;
|
||||||
|
supplier_business_item: string;
|
||||||
|
buyer_business_no: string;
|
||||||
|
buyer_name: string;
|
||||||
|
buyer_ceo_name: string;
|
||||||
|
buyer_address: string;
|
||||||
|
buyer_email: string;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
invoice_date: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
remarks: string;
|
||||||
|
order_id: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
attachments: TaxInvoiceAttachment[] | null;
|
||||||
|
cost_type: CostType | null; // 비용 유형
|
||||||
|
created_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
writer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 타입
|
||||||
|
export interface TaxInvoiceAttachment {
|
||||||
|
id: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
file_type: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
uploaded_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세금계산서 품목 타입
|
||||||
|
export interface TaxInvoiceItem {
|
||||||
|
id: string;
|
||||||
|
tax_invoice_id: string;
|
||||||
|
company_code: string;
|
||||||
|
item_seq: number;
|
||||||
|
item_date: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생성 DTO
|
||||||
|
export interface CreateTaxInvoiceDto {
|
||||||
|
invoice_type: "sales" | "purchase";
|
||||||
|
supplier_business_no?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
supplier_ceo_name?: string;
|
||||||
|
supplier_address?: string;
|
||||||
|
supplier_business_type?: string;
|
||||||
|
supplier_business_item?: string;
|
||||||
|
buyer_business_no?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
buyer_ceo_name?: string;
|
||||||
|
buyer_address?: string;
|
||||||
|
buyer_email?: string;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
invoice_date: string;
|
||||||
|
remarks?: string;
|
||||||
|
order_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
items?: CreateTaxInvoiceItemDto[];
|
||||||
|
attachments?: TaxInvoiceAttachment[];
|
||||||
|
cost_type?: CostType; // 비용 유형
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목 생성 DTO
|
||||||
|
export interface CreateTaxInvoiceItemDto {
|
||||||
|
item_date?: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 조회 파라미터
|
||||||
|
export interface TaxInvoiceListParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
invoice_type?: "sales" | "purchase";
|
||||||
|
invoice_status?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
cost_type?: CostType; // 비용 유형 필터
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 응답
|
||||||
|
export interface TaxInvoiceListResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: TaxInvoice[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 응답
|
||||||
|
export interface TaxInvoiceDetailResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
invoice: TaxInvoice;
|
||||||
|
items: TaxInvoiceItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 월별 통계 응답
|
||||||
|
export interface TaxInvoiceMonthlyStatsResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
};
|
||||||
|
period: { year: number; month: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getTaxInvoiceList(
|
||||||
|
params?: TaxInvoiceListParams
|
||||||
|
): Promise<TaxInvoiceListResponse> {
|
||||||
|
const response = await apiClient.get("/tax-invoice", { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getTaxInvoiceById(id: string): Promise<TaxInvoiceDetailResponse> {
|
||||||
|
const response = await apiClient.get(`/tax-invoice/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 생성
|
||||||
|
*/
|
||||||
|
export async function createTaxInvoice(
|
||||||
|
data: CreateTaxInvoiceDto
|
||||||
|
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||||
|
const response = await apiClient.post("/tax-invoice", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수정
|
||||||
|
*/
|
||||||
|
export async function updateTaxInvoice(
|
||||||
|
id: string,
|
||||||
|
data: Partial<CreateTaxInvoiceDto>
|
||||||
|
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||||
|
const response = await apiClient.put(`/tax-invoice/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteTaxInvoice(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await apiClient.delete(`/tax-invoice/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 발행
|
||||||
|
*/
|
||||||
|
export async function issueTaxInvoice(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||||
|
const response = await apiClient.post(`/tax-invoice/${id}/issue`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 취소
|
||||||
|
*/
|
||||||
|
export async function cancelTaxInvoice(
|
||||||
|
id: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||||
|
const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getTaxInvoiceMonthlyStats(
|
||||||
|
year?: number,
|
||||||
|
month?: number
|
||||||
|
): Promise<TaxInvoiceMonthlyStatsResponse> {
|
||||||
|
const params: Record<string, number> = {};
|
||||||
|
if (year) params.year = year;
|
||||||
|
if (month) params.month = month;
|
||||||
|
const response = await apiClient.get("/tax-invoice/stats/monthly", { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비용 유형별 통계 응답
|
||||||
|
export interface CostTypeStatsResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
by_cost_type: Array<{
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
by_month: Array<{
|
||||||
|
year_month: string;
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
total_count: number;
|
||||||
|
total_amount: number;
|
||||||
|
purchase_amount: number;
|
||||||
|
installation_amount: number;
|
||||||
|
repair_amount: number;
|
||||||
|
maintenance_amount: number;
|
||||||
|
disposal_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
period: { year?: number; month?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getCostTypeStats(
|
||||||
|
year?: number,
|
||||||
|
month?: number
|
||||||
|
): Promise<CostTypeStatsResponse> {
|
||||||
|
const params: Record<string, number> = {};
|
||||||
|
if (year) params.year = year;
|
||||||
|
if (month) params.month = month;
|
||||||
|
const response = await apiClient.get("/tax-invoice/stats/cost-type", { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) {
|
||||||
return response.data;
|
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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
||||||
// 🆕 범용 폼 모달 컴포넌트
|
// 🆕 범용 폼 모달 컴포넌트
|
||||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||||
|
|
||||||
|
// 🆕 세금계산서 관리 컴포넌트
|
||||||
|
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[]; // 검색 가능한 필드들
|
||||||
|
|
||||||
// 🆕 저장 대상 테이블 설정
|
// 🆕 저장 대상 테이블 설정
|
||||||
|
|
|
||||||
|
|
@ -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) && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; // 테이블 별칭 (중복 컬럼명 구분용)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메인 설정
|
* 메인 설정
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 컴포넌트 (레지스트리용 래퍼)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { TaxInvoiceList } from "@/components/tax-invoice";
|
||||||
|
import { TaxInvoiceListConfig } from "./types";
|
||||||
|
|
||||||
|
interface TaxInvoiceListComponentProps {
|
||||||
|
config?: TaxInvoiceListConfig;
|
||||||
|
componentId?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceListComponent({
|
||||||
|
config,
|
||||||
|
componentId,
|
||||||
|
isEditMode,
|
||||||
|
}: TaxInvoiceListComponentProps) {
|
||||||
|
// 편집 모드에서는 플레이스홀더 표시
|
||||||
|
if (isEditMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[300px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-muted-foreground mb-2 text-4xl">📄</div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">세금계산서 목록</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{config?.title || "세금계산서 관리"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full" style={{ height: config?.height || "auto" }}>
|
||||||
|
<TaxInvoiceList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 래퍼 컴포넌트 (레지스트리 호환용)
|
||||||
|
export function TaxInvoiceListWrapper(props: any) {
|
||||||
|
return <TaxInvoiceListComponent {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 설정 패널
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||||
|
|
||||||
|
interface TaxInvoiceListConfigPanelProps {
|
||||||
|
config: TaxInvoiceListConfig;
|
||||||
|
onChange: (config: TaxInvoiceListConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceListConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: TaxInvoiceListConfigPanelProps) {
|
||||||
|
const currentConfig = { ...defaultTaxInvoiceListConfig, ...config };
|
||||||
|
|
||||||
|
const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => {
|
||||||
|
onChange({ ...currentConfig, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기본 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">제목</Label>
|
||||||
|
<Input
|
||||||
|
value={currentConfig.title || ""}
|
||||||
|
onChange={(e) => handleChange("title", e.target.value)}
|
||||||
|
placeholder="세금계산서 관리"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">헤더 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.showHeader}
|
||||||
|
onCheckedChange={(checked) => handleChange("showHeader", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 필터 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">기본 필터</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">기본 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={currentConfig.defaultInvoiceType}
|
||||||
|
onValueChange={(v) => handleChange("defaultInvoiceType", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="sales">매출</SelectItem>
|
||||||
|
<SelectItem value="purchase">매입</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">기본 상태</Label>
|
||||||
|
<Select
|
||||||
|
value={currentConfig.defaultStatus}
|
||||||
|
onValueChange={(v) => handleChange("defaultStatus", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="draft">임시저장</SelectItem>
|
||||||
|
<SelectItem value="issued">발행완료</SelectItem>
|
||||||
|
<SelectItem value="sent">전송완료</SelectItem>
|
||||||
|
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">페이지당 항목 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(currentConfig.pageSize)}
|
||||||
|
onValueChange={(v) => handleChange("pageSize", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10개</SelectItem>
|
||||||
|
<SelectItem value="20">20개</SelectItem>
|
||||||
|
<SelectItem value="50">50개</SelectItem>
|
||||||
|
<SelectItem value="100">100개</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 권한 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">권한 설정</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">생성 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.canCreate}
|
||||||
|
onCheckedChange={(checked) => handleChange("canCreate", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">수정 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.canEdit}
|
||||||
|
onCheckedChange={(checked) => handleChange("canEdit", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">삭제 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.canDelete}
|
||||||
|
onCheckedChange={(checked) => handleChange("canDelete", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">발행 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.canIssue}
|
||||||
|
onCheckedChange={(checked) => handleChange("canIssue", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">취소 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={currentConfig.canCancel}
|
||||||
|
onCheckedChange={(checked) => handleChange("canCancel", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { TaxInvoiceListDefinition } from "./index";
|
||||||
|
import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = TaxInvoiceListDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <TaxInvoiceListComponent {...this.props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
TaxInvoiceListRenderer.registerSelf();
|
||||||
|
|
||||||
|
// 강제 등록 (디버깅용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
TaxInvoiceListRenderer.registerSelf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TaxInvoiceList 강제 등록 실패:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||||
|
import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||||
|
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 컴포넌트 정의
|
||||||
|
* 세금계산서 CRUD, 발행, 취소 기능을 제공하는 컴포넌트
|
||||||
|
*/
|
||||||
|
export const TaxInvoiceListDefinition = createComponentDefinition({
|
||||||
|
id: "tax-invoice-list",
|
||||||
|
name: "세금계산서 목록",
|
||||||
|
nameEng: "Tax Invoice List",
|
||||||
|
description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "text",
|
||||||
|
component: TaxInvoiceListWrapper,
|
||||||
|
defaultConfig: defaultTaxInvoiceListConfig,
|
||||||
|
defaultSize: { width: 1200, height: 700 },
|
||||||
|
configPanel: TaxInvoiceListConfigPanel,
|
||||||
|
icon: "FileText",
|
||||||
|
tags: ["세금계산서", "매출", "매입", "발행", "인보이스"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type { TaxInvoiceListConfig } from "./types";
|
||||||
|
export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||||
|
export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||||
|
export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 컴포넌트 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TaxInvoiceListConfig {
|
||||||
|
// 기본 설정
|
||||||
|
title?: string;
|
||||||
|
showHeader?: boolean;
|
||||||
|
|
||||||
|
// 필터 설정
|
||||||
|
defaultInvoiceType?: "all" | "sales" | "purchase";
|
||||||
|
defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled";
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
// 권한 설정
|
||||||
|
canCreate?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
canIssue?: boolean;
|
||||||
|
canCancel?: boolean;
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
height?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = {
|
||||||
|
title: "세금계산서 관리",
|
||||||
|
showHeader: true,
|
||||||
|
defaultInvoiceType: "all",
|
||||||
|
defaultStatus: "all",
|
||||||
|
pageSize: 20,
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
canIssue: true,
|
||||||
|
canCancel: true,
|
||||||
|
height: "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -100,6 +100,11 @@ export function UniversalFormModalComponent({
|
||||||
[key: string]: { value: string; label: string }[];
|
[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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue