lhj #253

Merged
hjlee merged 4 commits from lhj into main 2025-12-08 16:19:21 +09:00
20 changed files with 4357 additions and 7 deletions

View File

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

View File

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

View File

@ -0,0 +1,365 @@
/**
*
* API
*/
import { Request, Response } from "express";
import { TaxInvoiceService } from "../services/taxInvoiceService";
import { logger } from "../utils/logger";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
};
}
export class TaxInvoiceController {
/**
*
* GET /api/tax-invoice
*/
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const {
page = "1",
pageSize = "20",
invoice_type,
invoice_status,
start_date,
end_date,
search,
buyer_name,
cost_type,
} = req.query;
const result = await TaxInvoiceService.getList(companyCode, {
page: parseInt(page as string, 10),
pageSize: parseInt(pageSize as string, 10),
invoice_type: invoice_type as "sales" | "purchase" | undefined,
invoice_status: invoice_status as string | undefined,
start_date: start_date as string | undefined,
end_date: end_date as string | undefined,
search: search as string | undefined,
buyer_name: buyer_name as string | undefined,
cost_type: cost_type as any,
});
res.json({
success: true,
data: result.data,
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: Math.ceil(result.total / result.pageSize),
},
});
} catch (error: any) {
logger.error("세금계산서 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/:id
*/
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.getById(id, companyCode);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("세금계산서 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice
*/
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const data = req.body;
// 필수 필드 검증
if (!data.invoice_type) {
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
return;
}
if (!data.invoice_date) {
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
return;
}
if (data.supply_amount === undefined || data.supply_amount === null) {
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
return;
}
const result = await TaxInvoiceService.create(data, companyCode, userId);
res.status(201).json({
success: true,
data: result,
message: "세금계산서가 생성되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 생성 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
});
}
}
/**
*
* PUT /api/tax-invoice/:id
*/
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const data = req.body;
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 수정되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 수정 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
});
}
}
/**
*
* DELETE /api/tax-invoice/:id
*/
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.delete(id, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
message: "세금계산서가 삭제되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 삭제 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/issue
*/
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.issue(id, companyCode, userId);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 발행되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 발행 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/cancel
*/
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const { reason } = req.body;
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 취소되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 취소 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/monthly
*/
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const now = new Date();
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("월별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/cost-type
*/
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const targetYear = year ? parseInt(year as string, 10) : undefined;
const targetMonth = month ? parseInt(month as string, 10) : undefined;
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("비용 유형별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -28,6 +28,16 @@ export const errorHandler = (
// PostgreSQL 에러 처리 (pg 라이브러리)
if ((err as any).code) {
const pgError = err as any;
// 원본 에러 메시지 로깅 (디버깅용)
console.error("🔴 PostgreSQL Error:", {
code: pgError.code,
message: pgError.message,
detail: pgError.detail,
hint: pgError.hint,
table: pgError.table,
column: pgError.column,
constraint: pgError.constraint,
});
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") {
// unique_violation
@ -42,7 +52,7 @@ export const errorHandler = (
// 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
} else {
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
}
}

View File

@ -0,0 +1,43 @@
/**
*
* /api/tax-invoice
*/
import { Router } from "express";
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 목록 조회
router.get("/", TaxInvoiceController.getList);
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
// 비용 유형별 통계
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
// 상세 조회
router.get("/:id", TaxInvoiceController.getById);
// 생성
router.post("/", TaxInvoiceController.create);
// 수정
router.put("/:id", TaxInvoiceController.update);
// 삭제
router.delete("/:id", TaxInvoiceController.delete);
// 발행
router.post("/:id/issue", TaxInvoiceController.issue);
// 취소
router.post("/:id/cancel", TaxInvoiceController.cancel);
export default router;

View File

@ -1160,7 +1160,15 @@ export class DynamicFormService {
console.log("📝 실행할 DELETE SQL:", deleteQuery);
console.log("📊 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);

View File

@ -0,0 +1,784 @@
/**
*
* CRUD
*/
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
// 비용 유형 타입
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
// 세금계산서 타입 정의
export interface TaxInvoice {
id: string;
company_code: string;
invoice_number: string;
invoice_type: "sales" | "purchase"; // 매출/매입
invoice_status: "draft" | "issued" | "sent" | "cancelled";
// 공급자 정보
supplier_business_no: string;
supplier_name: string;
supplier_ceo_name: string;
supplier_address: string;
supplier_business_type: string;
supplier_business_item: string;
// 공급받는자 정보
buyer_business_no: string;
buyer_name: string;
buyer_ceo_name: string;
buyer_address: string;
buyer_email: string;
// 금액 정보
supply_amount: number;
tax_amount: number;
total_amount: number;
// 날짜 정보
invoice_date: string;
issue_date: string | null;
// 기타
remarks: string;
order_id: string | null;
customer_id: string | null;
// 첨부파일 (JSON 배열로 저장)
attachments: TaxInvoiceAttachment[] | null;
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
cost_type: CostType | null;
created_date: string;
updated_date: string;
writer: string;
}
// 첨부파일 타입
export interface TaxInvoiceAttachment {
id: string;
file_name: string;
file_path: string;
file_size: number;
file_type: string;
uploaded_at: string;
uploaded_by: string;
}
export interface TaxInvoiceItem {
id: string;
tax_invoice_id: string;
company_code: string;
item_seq: number;
item_date: string;
item_name: string;
item_spec: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks: string;
}
export interface CreateTaxInvoiceDto {
invoice_type: "sales" | "purchase";
supplier_business_no?: string;
supplier_name?: string;
supplier_ceo_name?: string;
supplier_address?: string;
supplier_business_type?: string;
supplier_business_item?: string;
buyer_business_no?: string;
buyer_name?: string;
buyer_ceo_name?: string;
buyer_address?: string;
buyer_email?: string;
supply_amount: number;
tax_amount: number;
total_amount: number;
invoice_date: string;
remarks?: string;
order_id?: string;
customer_id?: string;
items?: CreateTaxInvoiceItemDto[];
attachments?: TaxInvoiceAttachment[]; // 첨부파일
cost_type?: CostType; // 비용 유형
}
export interface CreateTaxInvoiceItemDto {
item_date?: string;
item_name: string;
item_spec?: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks?: string;
}
export interface TaxInvoiceListParams {
page?: number;
pageSize?: number;
invoice_type?: "sales" | "purchase";
invoice_status?: string;
start_date?: string;
end_date?: string;
search?: string;
buyer_name?: string;
cost_type?: CostType; // 비용 유형 필터
}
export class TaxInvoiceService {
/**
*
* 형식: YYYYMM-NNNNN (: 202512-00001)
*/
static async generateInvoiceNumber(companyCode: string): Promise<string> {
const now = new Date();
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
const prefix = `${yearMonth}-`;
// 해당 월의 마지막 번호 조회
const result = await query<{ max_num: string }>(
`SELECT invoice_number as max_num
FROM tax_invoice
WHERE company_code = $1
AND invoice_number LIKE $2
ORDER BY invoice_number DESC
LIMIT 1`,
[companyCode, `${prefix}%`]
);
let nextNum = 1;
if (result.length > 0 && result[0].max_num) {
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
nextNum = lastNum + 1;
}
return `${prefix}${String(nextNum).padStart(5, "0")}`;
}
/**
*
*/
static async getList(
companyCode: string,
params: TaxInvoiceListParams
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
const {
page = 1,
pageSize = 20,
invoice_type,
invoice_status,
start_date,
end_date,
search,
buyer_name,
cost_type,
} = params;
const offset = (page - 1) * pageSize;
const conditions: string[] = ["company_code = $1"];
const values: any[] = [companyCode];
let paramIndex = 2;
if (invoice_type) {
conditions.push(`invoice_type = $${paramIndex}`);
values.push(invoice_type);
paramIndex++;
}
if (invoice_status) {
conditions.push(`invoice_status = $${paramIndex}`);
values.push(invoice_status);
paramIndex++;
}
if (start_date) {
conditions.push(`invoice_date >= $${paramIndex}`);
values.push(start_date);
paramIndex++;
}
if (end_date) {
conditions.push(`invoice_date <= $${paramIndex}`);
values.push(end_date);
paramIndex++;
}
if (search) {
conditions.push(
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
);
values.push(`%${search}%`);
paramIndex++;
}
if (buyer_name) {
conditions.push(`buyer_name ILIKE $${paramIndex}`);
values.push(`%${buyer_name}%`);
paramIndex++;
}
if (cost_type) {
conditions.push(`cost_type = $${paramIndex}`);
values.push(cost_type);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 전체 개수 조회
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
values
);
const total = parseInt(countResult[0]?.count || "0", 10);
// 데이터 조회
values.push(pageSize, offset);
const data = await query<TaxInvoice>(
`SELECT * FROM tax_invoice
WHERE ${whereClause}
ORDER BY created_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
values
);
return { data, total, page, pageSize };
}
/**
* ( )
*/
static async getById(
id: string,
companyCode: string
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
const invoiceResult = await query<TaxInvoice>(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (invoiceResult.length === 0) {
return null;
}
const items = await query<TaxInvoiceItem>(
`SELECT * FROM tax_invoice_item
WHERE tax_invoice_id = $1 AND company_code = $2
ORDER BY item_seq`,
[id, companyCode]
);
return { invoice: invoiceResult[0], items };
}
/**
*
*/
static async create(
data: CreateTaxInvoiceDto,
companyCode: string,
userId: string
): Promise<TaxInvoice> {
return await transaction(async (client) => {
// 세금계산서 번호 채번
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
// 세금계산서 생성
const invoiceResult = await client.query(
`INSERT INTO tax_invoice (
company_code, invoice_number, invoice_type, invoice_status,
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
supplier_business_type, supplier_business_item,
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
supply_amount, tax_amount, total_amount, invoice_date,
remarks, order_id, customer_id, attachments, cost_type, writer
) VALUES (
$1, $2, $3, 'draft',
$4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22, $23, $24
) RETURNING *`,
[
companyCode,
invoiceNumber,
data.invoice_type,
data.supplier_business_no || null,
data.supplier_name || null,
data.supplier_ceo_name || null,
data.supplier_address || null,
data.supplier_business_type || null,
data.supplier_business_item || null,
data.buyer_business_no || null,
data.buyer_name || null,
data.buyer_ceo_name || null,
data.buyer_address || null,
data.buyer_email || null,
data.supply_amount,
data.tax_amount,
data.total_amount,
data.invoice_date,
data.remarks || null,
data.order_id || null,
data.customer_id || null,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type || null,
userId,
]
);
const invoice = invoiceResult.rows[0];
// 품목 생성
if (data.items && data.items.length > 0) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
await client.query(
`INSERT INTO tax_invoice_item (
tax_invoice_id, company_code, item_seq,
item_date, item_name, item_spec, quantity, unit_price,
supply_amount, tax_amount, remarks
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
invoice.id,
companyCode,
i + 1,
item.item_date || null,
item.item_name,
item.item_spec || null,
item.quantity,
item.unit_price,
item.supply_amount,
item.tax_amount,
item.remarks || null,
]
);
}
}
logger.info("세금계산서 생성 완료", {
invoiceId: invoice.id,
invoiceNumber,
companyCode,
userId,
});
return invoice;
});
}
/**
*
*/
static async update(
id: string,
data: Partial<CreateTaxInvoiceDto>,
companyCode: string,
userId: string
): Promise<TaxInvoice | null> {
return await transaction(async (client) => {
// 기존 세금계산서 확인
const existing = await client.query(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (existing.rows.length === 0) {
return null;
}
// 발행된 세금계산서는 수정 불가
if (existing.rows[0].invoice_status !== "draft") {
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
}
// 세금계산서 수정
const updateResult = await client.query(
`UPDATE tax_invoice SET
supplier_business_no = COALESCE($3, supplier_business_no),
supplier_name = COALESCE($4, supplier_name),
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
supplier_address = COALESCE($6, supplier_address),
supplier_business_type = COALESCE($7, supplier_business_type),
supplier_business_item = COALESCE($8, supplier_business_item),
buyer_business_no = COALESCE($9, buyer_business_no),
buyer_name = COALESCE($10, buyer_name),
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
buyer_address = COALESCE($12, buyer_address),
buyer_email = COALESCE($13, buyer_email),
supply_amount = COALESCE($14, supply_amount),
tax_amount = COALESCE($15, tax_amount),
total_amount = COALESCE($16, total_amount),
invoice_date = COALESCE($17, invoice_date),
remarks = COALESCE($18, remarks),
attachments = $19,
cost_type = COALESCE($20, cost_type),
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING *`,
[
id,
companyCode,
data.supplier_business_no,
data.supplier_name,
data.supplier_ceo_name,
data.supplier_address,
data.supplier_business_type,
data.supplier_business_item,
data.buyer_business_no,
data.buyer_name,
data.buyer_ceo_name,
data.buyer_address,
data.buyer_email,
data.supply_amount,
data.tax_amount,
data.total_amount,
data.invoice_date,
data.remarks,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type,
]
);
// 품목 업데이트 (기존 삭제 후 재생성)
if (data.items) {
await client.query(
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
[id, companyCode]
);
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
await client.query(
`INSERT INTO tax_invoice_item (
tax_invoice_id, company_code, item_seq,
item_date, item_name, item_spec, quantity, unit_price,
supply_amount, tax_amount, remarks
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
id,
companyCode,
i + 1,
item.item_date || null,
item.item_name,
item.item_spec || null,
item.quantity,
item.unit_price,
item.supply_amount,
item.tax_amount,
item.remarks || null,
]
);
}
}
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
return updateResult.rows[0];
});
}
/**
*
*/
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
return await transaction(async (client) => {
// 기존 세금계산서 확인
const existing = await client.query(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (existing.rows.length === 0) {
return false;
}
// 발행된 세금계산서는 삭제 불가
if (existing.rows[0].invoice_status !== "draft") {
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
}
// 품목 삭제
await client.query(
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
[id, companyCode]
);
// 세금계산서 삭제
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
id,
companyCode,
]);
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
return true;
});
}
/**
* ( )
*/
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`UPDATE tax_invoice SET
invoice_status = 'issued',
issue_date = NOW(),
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
RETURNING *`,
[id, companyCode]
);
if (result.length === 0) {
return null;
}
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
return result[0];
}
/**
*
*/
static async cancel(
id: string,
companyCode: string,
userId: string,
reason?: string
): Promise<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`UPDATE tax_invoice SET
invoice_status = 'cancelled',
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
RETURNING *`,
[id, companyCode, reason || null]
);
if (result.length === 0) {
return null;
}
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
return result[0];
}
/**
*
*/
static async getMonthlyStats(
companyCode: string,
year: number,
month: number
): Promise<{
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
}> {
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
const result = await query<{
invoice_type: string;
count: string;
supply_amount: string;
tax_amount: string;
total_amount: string;
}>(
`SELECT
invoice_type,
COUNT(*) as count,
COALESCE(SUM(supply_amount), 0) as supply_amount,
COALESCE(SUM(tax_amount), 0) as tax_amount,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE company_code = $1
AND invoice_date >= $2
AND invoice_date <= $3
AND invoice_status != 'cancelled'
GROUP BY invoice_type`,
[companyCode, startDate, endDate]
);
const stats = {
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
};
for (const row of result) {
const type = row.invoice_type as "sales" | "purchase";
stats[type] = {
count: parseInt(row.count, 10),
supply_amount: parseFloat(row.supply_amount),
tax_amount: parseFloat(row.tax_amount),
total_amount: parseFloat(row.total_amount),
};
}
return stats;
}
/**
*
*/
static async getCostTypeStats(
companyCode: string,
year?: number,
month?: number
): Promise<{
by_cost_type: Array<{
cost_type: CostType | null;
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}>;
by_month: Array<{
year_month: string;
cost_type: CostType | null;
count: number;
total_amount: number;
}>;
summary: {
total_count: number;
total_amount: number;
purchase_amount: number;
installation_amount: number;
repair_amount: number;
maintenance_amount: number;
disposal_amount: number;
other_amount: number;
};
}> {
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
const values: any[] = [companyCode];
let paramIndex = 2;
// 연도/월 필터
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
values.push(startDate, endDate);
paramIndex += 2;
} else if (year) {
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
values.push(year);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 비용 유형별 집계
const byCostType = await query<{
cost_type: CostType | null;
count: string;
supply_amount: string;
tax_amount: string;
total_amount: string;
}>(
`SELECT
cost_type,
COUNT(*) as count,
COALESCE(SUM(supply_amount), 0) as supply_amount,
COALESCE(SUM(tax_amount), 0) as tax_amount,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY cost_type
ORDER BY total_amount DESC`,
values
);
// 월별 비용 유형 집계
const byMonth = await query<{
year_month: string;
cost_type: CostType | null;
count: string;
total_amount: string;
}>(
`SELECT
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
cost_type,
COUNT(*) as count,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
ORDER BY year_month DESC, cost_type`,
values
);
// 전체 요약
const summaryResult = await query<{
total_count: string;
total_amount: string;
purchase_amount: string;
installation_amount: string;
repair_amount: string;
maintenance_amount: string;
disposal_amount: string;
other_amount: string;
}>(
`SELECT
COUNT(*) as total_count,
COALESCE(SUM(total_amount), 0) as total_amount,
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
FROM tax_invoice
WHERE ${whereClause}`,
values
);
const summary = summaryResult[0] || {
total_count: "0",
total_amount: "0",
purchase_amount: "0",
installation_amount: "0",
repair_amount: "0",
maintenance_amount: "0",
disposal_amount: "0",
other_amount: "0",
};
return {
by_cost_type: byCostType.map((row) => ({
cost_type: row.cost_type,
count: parseInt(row.count, 10),
supply_amount: parseFloat(row.supply_amount),
tax_amount: parseFloat(row.tax_amount),
total_amount: parseFloat(row.total_amount),
})),
by_month: byMonth.map((row) => ({
year_month: row.year_month,
cost_type: row.cost_type,
count: parseInt(row.count, 10),
total_amount: parseFloat(row.total_amount),
})),
summary: {
total_count: parseInt(summary.total_count, 10),
total_amount: parseFloat(summary.total_amount),
purchase_amount: parseFloat(summary.purchase_amount),
installation_amount: parseFloat(summary.installation_amount),
repair_amount: parseFloat(summary.repair_amount),
maintenance_amount: parseFloat(summary.maintenance_amount),
disposal_amount: parseFloat(summary.disposal_amount),
other_amount: parseFloat(summary.other_amount),
},
};
}
}

View File

@ -6,6 +6,11 @@
*/
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
} from "@/components/ui/dialog";
import {
ResizableDialog,
ResizableDialogContent,
@ -137,7 +142,9 @@ export function TableHistoryModal({
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
const date = new Date(dateString);
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
} catch {
return dateString;
}

View File

@ -0,0 +1,329 @@
"use client";
/**
*
* ////
*/
import { useState, useEffect, useCallback } from "react";
import {
BarChart3,
TrendingUp,
TrendingDown,
Package,
Wrench,
Settings,
Trash2,
DollarSign,
Calendar,
RefreshCw,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice";
// 비용 유형별 아이콘
const costTypeIcons: Record<CostType, React.ReactNode> = {
purchase: <Package className="h-4 w-4" />,
installation: <Settings className="h-4 w-4" />,
repair: <Wrench className="h-4 w-4" />,
maintenance: <Settings className="h-4 w-4" />,
disposal: <Trash2 className="h-4 w-4" />,
other: <DollarSign className="h-4 w-4" />,
};
// 비용 유형별 색상
const costTypeColors: Record<CostType, string> = {
purchase: "bg-blue-500",
installation: "bg-green-500",
repair: "bg-orange-500",
maintenance: "bg-purple-500",
disposal: "bg-red-500",
other: "bg-gray-500",
};
export function CostTypeStats() {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<CostTypeStatsResponse["data"] | null>(null);
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(undefined);
// 연도 옵션 생성 (최근 5년)
const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
// 월 옵션 생성
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
// 데이터 로드
const loadStats = useCallback(async () => {
setLoading(true);
try {
const response = await getCostTypeStats(selectedYear, selectedMonth);
if (response.success) {
setStats(response.data);
}
} catch (error: any) {
toast.error("통계 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
}, [selectedYear, selectedMonth]);
useEffect(() => {
loadStats();
}, [loadStats]);
// 금액 포맷
const formatAmount = (amount: number) => {
if (amount >= 100000000) {
return `${(amount / 100000000).toFixed(1)}`;
}
if (amount >= 10000) {
return `${(amount / 10000).toFixed(0)}`;
}
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 전체 금액 대비 비율 계산
const getPercentage = (amount: number) => {
if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0;
return (amount / stats.summary.total_amount) * 100;
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold"> </h2>
<p className="text-muted-foreground text-sm">//// </p>
</div>
<div className="flex items-center gap-2">
<Select
value={String(selectedYear)}
onValueChange={(v) => setSelectedYear(parseInt(v, 10))}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth ? String(selectedMonth) : "all"}
onValueChange={(v) => setSelectedMonth(v === "all" ? undefined : parseInt(v, 10))}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{monthOptions.map((month) => (
<SelectItem key={month} value={String(month)}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={loadStats} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<DollarSign className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount(stats?.summary.total_amount || 0)}
</div>
<p className="text-muted-foreground text-xs">
{stats?.summary.total_count || 0}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<Package className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount(stats?.summary.purchase_amount || 0)}
</div>
<Progress
value={getPercentage(stats?.summary.purchase_amount || 0)}
className="mt-2 h-1"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">/</CardTitle>
<Wrench className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
</div>
<Progress
value={getPercentage((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
className="mt-2 h-1"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">/</CardTitle>
<Settings className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
</div>
<Progress
value={getPercentage((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
className="mt-2 h-1"
/>
</CardContent>
</Card>
</div>
{/* 비용 유형별 상세 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stats?.by_cost_type && stats.by_cost_type.length > 0 ? (
stats.by_cost_type.map((item) => {
const costType = item.cost_type as CostType;
const percentage = getPercentage(item.total_amount);
return (
<div key={costType || "none"} className="flex items-center gap-4">
<div className="flex w-[120px] items-center gap-2">
{costType && costTypeIcons[costType]}
<span className="text-sm font-medium">
{costType ? costTypeLabels[costType] : "미분류"}
</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${costType ? costTypeColors[costType] : "bg-gray-400"}`}
style={{ width: `${Math.max(percentage, 2)}%` }}
/>
</div>
<span className="w-[50px] text-right text-sm text-muted-foreground">
{percentage.toFixed(1)}%
</span>
</div>
</div>
<div className="w-[120px] text-right">
<div className="font-mono text-sm font-semibold">
{formatAmount(item.total_amount)}
</div>
<div className="text-xs text-muted-foreground">{item.count}</div>
</div>
</div>
);
})
) : (
<div className="text-center py-8 text-muted-foreground">
.
</div>
)}
</div>
</CardContent>
</Card>
{/* 월별 추이 */}
{!selectedMonth && stats?.by_month && stats.by_month.length > 0 && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>{selectedYear} </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 월별 그룹핑 */}
{Array.from(new Set(stats.by_month.map((item) => item.year_month)))
.sort()
.reverse()
.slice(0, 6)
.map((yearMonth) => {
const monthData = stats.by_month.filter((item) => item.year_month === yearMonth);
const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0);
const [year, month] = yearMonth.split("-");
return (
<div key={yearMonth} className="flex items-center gap-4 py-2 border-b last:border-0">
<div className="w-[80px] text-sm font-medium">
{month}
</div>
<div className="flex-1 flex gap-1">
{monthData.map((item) => {
const costType = item.cost_type as CostType;
const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0;
return (
<div
key={costType || "none"}
className={`h-6 ${costType ? costTypeColors[costType] : "bg-gray-400"} rounded`}
style={{ width: `${Math.max(width, 5)}%` }}
title={`${costType ? costTypeLabels[costType] : "미분류"}: ${formatAmount(item.total_amount)}`}
/>
);
})}
</div>
<div className="w-[100px] text-right font-mono text-sm">
{formatAmount(monthTotal)}
</div>
</div>
);
})}
</div>
{/* 범례 */}
<div className="mt-4 flex flex-wrap gap-3 pt-4 border-t">
{Object.entries(costTypeLabels).map(([key, label]) => (
<div key={key} className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${costTypeColors[key as CostType]}`} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,621 @@
"use client";
/**
*
* PDF
*/
import { useState, useEffect, useRef } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
Printer,
Download,
FileText,
Image,
File,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import {
getTaxInvoiceById,
TaxInvoice,
TaxInvoiceItem,
TaxInvoiceAttachment,
} from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client";
interface TaxInvoiceDetailProps {
open: boolean;
onClose: () => void;
invoiceId: string;
}
// 상태 라벨
const statusLabels: Record<string, string> = {
draft: "임시저장",
issued: "발행완료",
sent: "전송완료",
cancelled: "취소됨",
};
// 상태 색상
const statusColors: Record<string, string> = {
draft: "bg-gray-100 text-gray-800",
issued: "bg-green-100 text-green-800",
sent: "bg-blue-100 text-blue-800",
cancelled: "bg-red-100 text-red-800",
};
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
const [loading, setLoading] = useState(true);
const [pdfLoading, setPdfLoading] = useState(false);
const printRef = useRef<HTMLDivElement>(null);
// 데이터 로드
useEffect(() => {
if (open && invoiceId) {
loadData();
}
}, [open, invoiceId]);
const loadData = async () => {
setLoading(true);
try {
const response = await getTaxInvoiceById(invoiceId);
if (response.success) {
setInvoice(response.data.invoice);
setItems(response.data.items);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 날짜 포맷
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
} catch {
return dateString;
}
};
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
// objid가 숫자형이면 API를 통해 미리보기
if (attachment.id && !attachment.id.includes("-")) {
// apiClient의 baseURL 사용
const baseURL = apiClient.defaults.baseURL || "";
return `${baseURL}/files/preview/${attachment.id}`;
}
return attachment.file_path;
};
// 공통 인쇄용 HTML 생성 함수
const generatePrintHtml = (autoPrint: boolean = false) => {
if (!invoice) return "";
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
const itemsHtml = items.map((item, index) => `
<tr>
<td style="text-align:center">${index + 1}</td>
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
<td>${item.item_name}</td>
<td>${item.item_spec || "-"}</td>
<td style="text-align:right">${item.quantity}</td>
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
</tr>
`).join("");
return `
<!DOCTYPE html>
<html>
<head>
<title>_${invoice.invoice_number}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
.container { max-width: 800px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
.header h1 { font-size: 28px; margin-bottom: 10px; }
.header .invoice-number { font-size: 14px; color: #666; }
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
.status-draft { background: #f3f4f6; color: #374151; }
.status-issued { background: #d1fae5; color: #065f46; }
.status-sent { background: #dbeafe; color: #1e40af; }
.status-cancelled { background: #fee2e2; color: #991b1b; }
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
.party-label { width: 80px; color: #666; }
.party-value { flex: 1; }
.items-section { margin-bottom: 30px; }
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f9fafb; font-weight: 600; }
.total-section { display: flex; justify-content: flex-end; }
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
.total-row.grand .value { color: #1d4ed8; }
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.attachments ul { list-style: none; font-size: 12px; }
.attachments li { padding: 4px 0; }
@media print {
body { padding: 15px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1> (${invoiceTypeText})</h1>
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
</div>
<div class="parties">
<div class="party">
<h3></h3>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
<div class="party-row"><span class="party-label">/</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
</div>
<div class="party">
<h3></h3>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
<div class="party-row"><span class="party-label"></span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
</div>
</div>
<div class="items-section">
<h3> </h3>
<table>
<thead>
<tr>
<th style="width:40px">No</th>
<th style="width:80px"></th>
<th></th>
<th style="width:70px"></th>
<th style="width:50px"></th>
<th style="width:80px"></th>
<th style="width:90px"></th>
<th style="width:70px"></th>
</tr>
</thead>
<tbody>
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
</tbody>
</table>
</div>
<div class="total-section">
<div class="total-box">
<div class="total-row"><span></span><span>${formatAmount(invoice.supply_amount)}</span></div>
<div class="total-row"><span></span><span>${formatAmount(invoice.tax_amount)}</span></div>
<div class="total-row grand"><span></span><span class="value">${formatAmount(invoice.total_amount)}</span></div>
</div>
</div>
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
${invoice.attachments && invoice.attachments.length > 0 ? `
<div class="attachments">
<h3> (${invoice.attachments.length})</h3>
<ul>
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
</ul>
</div>
` : ""}
<div class="footer">
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
</div>
</div>
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
</body>
</html>
`;
};
// 인쇄
const handlePrint = () => {
if (!invoice) return;
const printWindow = window.open("", "_blank");
if (!printWindow) {
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
return;
}
printWindow.document.write(generatePrintHtml(true));
printWindow.document.close();
};
// PDF 다운로드 (인쇄 다이얼로그 사용)
const handleDownloadPdf = async () => {
if (!invoice) return;
setPdfLoading(true);
try {
const printWindow = window.open("", "_blank");
if (!printWindow) {
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
return;
}
printWindow.document.write(generatePrintHtml(true));
printWindow.document.close();
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
} catch (error: any) {
console.error("PDF 생성 오류:", error);
toast.error("PDF 생성 실패", { description: error.message });
} finally {
setPdfLoading(false);
}
};
// 파일 아이콘
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
return <File className="h-4 w-4" />;
};
// 파일 다운로드 (인증 토큰 포함)
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
try {
// objid가 숫자형이면 API를 통해 다운로드
if (attachment.id && !attachment.id.includes("-")) {
const response = await apiClient.get(`/files/download/${attachment.id}`, {
responseType: "blob",
});
// Blob으로 다운로드
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = attachment.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} else {
// 직접 경로로 다운로드
window.open(attachment.file_path, "_blank");
}
} catch (error: any) {
toast.error("파일 다운로드 실패", { description: error.message });
}
};
if (loading) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="flex h-[400px] items-center justify-center">
<span className="text-muted-foreground"> ...</span>
</div>
</DialogContent>
</Dialog>
);
}
if (!invoice) {
return null;
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
<DialogTitle> </DialogTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-1 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={pdfLoading}
>
{pdfLoading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1 h-4 w-4" />
)}
PDF
</Button>
</div>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-120px)]">
<div className="p-6" ref={printRef}>
<div className="invoice-container">
{/* 헤더 */}
<div className="mb-6 text-center">
<h1 className="mb-2 text-2xl font-bold">
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
</h1>
<p className="text-muted-foreground text-sm">
: {invoice.invoice_number}
</p>
<Badge className={statusColors[invoice.invoice_status]}>
{statusLabels[invoice.invoice_status]}
</Badge>
</div>
{/* 공급자 / 공급받는자 정보 */}
<div className="mb-6 grid grid-cols-2 gap-6">
{/* 공급자 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 border-b pb-2 font-semibold"></h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_business_no || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_ceo_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24">/</span>
<span>
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span className="flex-1">{invoice.supplier_address || "-"}</span>
</div>
</div>
</div>
{/* 공급받는자 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 border-b pb-2 font-semibold"></h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_business_no || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_ceo_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_email || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span className="flex-1">{invoice.buyer_address || "-"}</span>
</div>
</div>
</div>
</div>
{/* 품목 내역 */}
<div className="mb-6">
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold"> </h3>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
<TableCell>{item.item_name}</TableCell>
<TableCell>{item.item_spec || "-"}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.unit_price)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.supply_amount)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.tax_amount)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 합계 */}
<div className="flex justify-end">
<div className="w-[300px] space-y-2 rounded-lg border p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(invoice.supply_amount)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(invoice.tax_amount)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span></span>
<span className="font-mono text-primary">
{formatAmount(invoice.total_amount)}
</span>
</div>
</div>
</div>
{/* 비고 */}
{invoice.remarks && (
<div className="mt-6">
<h3 className="mb-2 font-semibold"></h3>
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
{invoice.remarks}
</p>
</div>
)}
{/* 날짜 정보 */}
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
<span>: {formatDate(invoice.invoice_date)}</span>
{invoice.issue_date && <span>: {formatDate(invoice.issue_date)}</span>}
</div>
</div>
{/* 첨부파일 */}
{invoice.attachments && invoice.attachments.length > 0 && (
<div className="mt-6">
<Separator className="mb-4" />
<h3 className="mb-3 font-semibold"> ({invoice.attachments.length})</h3>
{/* 이미지 미리보기 */}
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{invoice.attachments
.filter((f) => f.file_type?.startsWith("image/"))
.map((file) => (
<div
key={file.id}
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
>
<img
src={getFilePreviewUrl(file)}
alt={file.file_name}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
<div className="w-full p-2">
<p className="truncate text-xs text-white">{file.file_name}</p>
<Button
variant="secondary"
size="sm"
className="mt-1 h-7 w-full text-xs"
onClick={() => handleDownload(file)}
>
<Download className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
{/* 기타 파일 목록 */}
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
<div className="space-y-2">
{invoice.attachments
.filter((f) => !f.file_type?.startsWith("image/"))
.map((file) => (
<div
key={file.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
{getFileIcon(file.file_type)}
<span className="text-sm">{file.file_name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(file)}
>
<Download className="mr-1 h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,728 @@
"use client";
/**
* /
*
*/
import { useState, useEffect, useCallback } from "react";
import { format } from "date-fns";
import {
Plus,
Trash2,
Upload,
X,
FileText,
Image,
File,
Paperclip,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
createTaxInvoice,
updateTaxInvoice,
getTaxInvoiceById,
TaxInvoice,
TaxInvoiceAttachment,
CreateTaxInvoiceDto,
CreateTaxInvoiceItemDto,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client";
interface TaxInvoiceFormProps {
open: boolean;
onClose: () => void;
onSave: () => void;
invoice?: TaxInvoice | null;
}
// 품목 초기값
const emptyItem: CreateTaxInvoiceItemDto = {
item_date: format(new Date(), "yyyy-MM-dd"),
item_name: "",
item_spec: "",
quantity: 1,
unit_price: 0,
supply_amount: 0,
tax_amount: 0,
remarks: "",
};
export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) {
// 폼 상태
const [formData, setFormData] = useState<CreateTaxInvoiceDto>({
invoice_type: "sales",
invoice_date: format(new Date(), "yyyy-MM-dd"),
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [{ ...emptyItem }],
});
// 첨부파일 상태
const [attachments, setAttachments] = useState<TaxInvoiceAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState("basic");
// 수정 모드일 때 데이터 로드
useEffect(() => {
if (invoice) {
loadInvoiceData(invoice.id);
} else {
// 새 세금계산서
setFormData({
invoice_type: "sales",
invoice_date: format(new Date(), "yyyy-MM-dd"),
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [{ ...emptyItem }],
});
setAttachments([]);
}
}, [invoice]);
// 세금계산서 데이터 로드
const loadInvoiceData = async (id: string) => {
try {
const response = await getTaxInvoiceById(id);
if (response.success) {
const { invoice: inv, items } = response.data;
setFormData({
invoice_type: inv.invoice_type,
invoice_date: inv.invoice_date?.split("T")[0] || "",
supplier_business_no: inv.supplier_business_no,
supplier_name: inv.supplier_name,
supplier_ceo_name: inv.supplier_ceo_name,
supplier_address: inv.supplier_address,
supplier_business_type: inv.supplier_business_type,
supplier_business_item: inv.supplier_business_item,
buyer_business_no: inv.buyer_business_no,
buyer_name: inv.buyer_name,
buyer_ceo_name: inv.buyer_ceo_name,
buyer_address: inv.buyer_address,
buyer_email: inv.buyer_email,
supply_amount: inv.supply_amount,
tax_amount: inv.tax_amount,
total_amount: inv.total_amount,
remarks: inv.remarks,
cost_type: inv.cost_type || undefined,
items:
items.length > 0
? items.map((item) => ({
item_date: item.item_date?.split("T")[0] || "",
item_name: item.item_name,
item_spec: item.item_spec,
quantity: item.quantity,
unit_price: item.unit_price,
supply_amount: item.supply_amount,
tax_amount: item.tax_amount,
remarks: item.remarks,
}))
: [{ ...emptyItem }],
});
setAttachments(inv.attachments || []);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
}
};
// 필드 변경
const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 품목 변경
const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => {
setFormData((prev) => {
const items = [...(prev.items || [])];
items[index] = { ...items[index], [field]: value };
// 공급가액 자동 계산
if (field === "quantity" || field === "unit_price") {
const qty = field === "quantity" ? value : items[index].quantity;
const price = field === "unit_price" ? value : items[index].unit_price;
items[index].supply_amount = qty * price;
items[index].tax_amount = Math.round(items[index].supply_amount * 0.1);
}
// 총액 재계산
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
return {
...prev,
items,
supply_amount: totalSupply,
tax_amount: totalTax,
total_amount: totalSupply + totalTax,
};
});
};
// 품목 추가
const handleAddItem = () => {
setFormData((prev) => ({
...prev,
items: [...(prev.items || []), { ...emptyItem }],
}));
};
// 품목 삭제
const handleRemoveItem = (index: number) => {
setFormData((prev) => {
const items = (prev.items || []).filter((_, i) => i !== index);
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
return {
...prev,
items: items.length > 0 ? items : [{ ...emptyItem }],
supply_amount: totalSupply,
tax_amount: totalTax,
total_amount: totalSupply + totalTax,
};
});
};
// 파일 업로드
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const formDataUpload = new FormData();
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
formDataUpload.append("category", "tax-invoice");
const response = await apiClient.post("/files/upload", formDataUpload, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data.success && response.data.files?.length > 0) {
const uploadedFile = response.data.files[0];
const newAttachment: TaxInvoiceAttachment = {
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file_name: uploadedFile.realFileName || file.name,
file_path: uploadedFile.filePath,
file_size: uploadedFile.fileSize || file.size,
file_type: file.type,
uploaded_at: new Date().toISOString(),
uploaded_by: "",
};
setAttachments((prev) => [...prev, newAttachment]);
toast.success(`'${file.name}' 업로드 완료`);
}
}
} catch (error: any) {
toast.error("파일 업로드 실패", { description: error.message });
} finally {
setUploading(false);
// input 초기화
e.target.value = "";
}
};
// 첨부파일 삭제
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// 파일 아이콘
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
return <File className="h-4 w-4" />;
};
// 파일 크기 포맷
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// 저장
const handleSave = async () => {
// 유효성 검사
if (!formData.invoice_date) {
toast.error("작성일자를 입력해주세요.");
return;
}
setSaving(true);
try {
const dataToSave = {
...formData,
attachments,
};
let response;
if (invoice) {
response = await updateTaxInvoice(invoice.id, dataToSave);
} else {
response = await createTaxInvoice(dataToSave);
}
if (response.success) {
toast.success(response.message || "저장되었습니다.");
onSave();
}
} catch (error: any) {
toast.error("저장 실패", { description: error.message });
} finally {
setSaving(false);
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] max-w-[900px] overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{invoice ? "세금계산서 수정" : "세금계산서 작성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-180px)]">
<div className="space-y-4 p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="supplier"></TabsTrigger>
<TabsTrigger value="buyer"></TabsTrigger>
<TabsTrigger value="attachments">
{attachments.length > 0 && (
<Badge variant="secondary" className="ml-2">
{attachments.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* 기본정보 탭 */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Label className="text-xs"> *</Label>
<Select
value={formData.invoice_type}
onValueChange={(v) => handleChange("invoice_type", v)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sales"></SelectItem>
<SelectItem value="purchase"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={formData.cost_type || "none"}
onValueChange={(v) => handleChange("cost_type", v === "none" ? undefined : v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{Object.entries(costTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> *</Label>
<Input
type="date"
value={formData.invoice_date}
onChange={(e) => handleChange("invoice_date", e.target.value)}
className="h-9"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.remarks || ""}
onChange={(e) => handleChange("remarks", e.target.value)}
className="h-9"
placeholder="비고 입력"
/>
</div>
</div>
{/* 품목 테이블 */}
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"> </CardTitle>
<Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(formData.items || []).map((item, index) => (
<TableRow key={index}>
<TableCell className="p-1">
<Input
type="date"
value={item.item_date || ""}
onChange={(e) =>
handleItemChange(index, "item_date", e.target.value)
}
className="h-8 text-xs"
/>
</TableCell>
<TableCell className="p-1">
<Input
value={item.item_name || ""}
onChange={(e) =>
handleItemChange(index, "item_name", e.target.value)
}
className="h-8 text-xs"
placeholder="품목명"
/>
</TableCell>
<TableCell className="p-1">
<Input
value={item.item_spec || ""}
onChange={(e) =>
handleItemChange(index, "item_spec", e.target.value)
}
className="h-8 text-xs"
placeholder="규격"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.quantity || 0}
onChange={(e) =>
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
}
className="h-8 text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.unit_price || 0}
onChange={(e) =>
handleItemChange(
index,
"unit_price",
parseFloat(e.target.value) || 0
)
}
className="h-8 text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-1 text-right font-mono text-xs">
{formatAmount(item.supply_amount || 0)}
</TableCell>
<TableCell className="p-1 text-right font-mono text-xs">
{formatAmount(item.tax_amount || 0)}
</TableCell>
<TableCell className="p-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRemoveItem(index)}
disabled={(formData.items?.length || 0) <= 1}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 합계 */}
<div className="flex justify-end">
<div className="w-[300px] space-y-2 rounded-lg border p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(formData.supply_amount || 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}</span>
</div>
<div className="flex justify-between border-t pt-2 text-lg font-bold">
<span></span>
<span className="font-mono text-primary">
{formatAmount(formData.total_amount || 0)}
</span>
</div>
</div>
</div>
</TabsContent>
{/* 공급자 탭 */}
<TabsContent value="supplier" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_no || ""}
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
className="h-9"
placeholder="000-00-00000"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_name || ""}
onChange={(e) => handleChange("supplier_name", e.target.value)}
className="h-9"
placeholder="상호명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_ceo_name || ""}
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
className="h-9"
placeholder="대표자명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_type || ""}
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
className="h-9"
placeholder="업태"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_item || ""}
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
className="h-9"
placeholder="종목"
/>
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input
value={formData.supplier_address || ""}
onChange={(e) => handleChange("supplier_address", e.target.value)}
className="h-9"
placeholder="주소"
/>
</div>
</div>
</TabsContent>
{/* 공급받는자 탭 */}
<TabsContent value="buyer" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_business_no || ""}
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
className="h-9"
placeholder="000-00-00000"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_name || ""}
onChange={(e) => handleChange("buyer_name", e.target.value)}
className="h-9"
placeholder="상호명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_ceo_name || ""}
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
className="h-9"
placeholder="대표자명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="email"
value={formData.buyer_email || ""}
onChange={(e) => handleChange("buyer_email", e.target.value)}
className="h-9"
placeholder="email@example.com"
/>
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input
value={formData.buyer_address || ""}
onChange={(e) => handleChange("buyer_address", e.target.value)}
className="h-9"
placeholder="주소"
/>
</div>
</div>
</TabsContent>
{/* 첨부파일 탭 */}
<TabsContent value="attachments" className="space-y-4">
{/* 업로드 영역 */}
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<input
type="file"
id="file-upload"
multiple
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
/>
<label
htmlFor="file-upload"
className="flex cursor-pointer flex-col items-center gap-2"
>
<Upload className="text-muted-foreground h-8 w-8" />
<span className="text-muted-foreground text-sm">
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
</span>
<span className="text-muted-foreground text-xs">
PDF, , ( 10MB)
</span>
</label>
</div>
{/* 첨부파일 목록 */}
{attachments.length > 0 && (
<div className="space-y-2">
<Label className="text-xs"> ({attachments.length})</Label>
<div className="space-y-2">
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
{getFileIcon(file.file_type)}
<div>
<p className="text-sm font-medium">{file.file_name}</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(file.file_size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveAttachment(file.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{attachments.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
.
</div>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<DialogFooter className="border-t px-6 py-4">
<Button variant="outline" onClick={onClose} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,832 @@
"use client";
/**
*
* , ,
*/
import { useState, useEffect, useCallback } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
Plus,
Search,
Filter,
FileText,
Eye,
Edit,
Trash2,
Send,
CheckCircle,
XCircle,
Clock,
RefreshCw,
Paperclip,
Image,
File,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import {
getTaxInvoiceList,
deleteTaxInvoice,
issueTaxInvoice,
cancelTaxInvoice,
TaxInvoice,
TaxInvoiceListParams,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { TaxInvoiceForm } from "./TaxInvoiceForm";
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
// 상태 뱃지 색상
const statusBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
issued: "default",
sent: "secondary",
cancelled: "destructive",
};
// 상태 라벨
const statusLabels: Record<string, string> = {
draft: "임시저장",
issued: "발행완료",
sent: "전송완료",
cancelled: "취소됨",
};
// 유형 라벨
const typeLabels: Record<string, string> = {
sales: "매출",
purchase: "매입",
};
// 컬럼 정의
interface ColumnDef {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
filterType?: "text" | "select";
filterOptions?: { value: string; label: string }[];
width?: string;
align?: "left" | "center" | "right";
}
const columns: ColumnDef[] = [
{ key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" },
{ key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select",
filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "70px" },
{ key: "cost_type", label: "비용유형", sortable: true, filterable: true, filterType: "select",
filterOptions: Object.entries(costTypeLabels).map(([value, label]) => ({ value, label })), width: "90px" },
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
filterOptions: [
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
], width: "90px" },
{ key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" },
{ key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" },
{ key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" },
{ key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" },
{ key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" },
{ key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" },
];
export function TaxInvoiceList() {
// 상태
const [invoices, setInvoices] = useState<TaxInvoice[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
});
// 필터 상태
const [filters, setFilters] = useState<TaxInvoiceListParams>({
page: 1,
pageSize: 20,
});
const [searchText, setSearchText] = useState("");
// 정렬 상태
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 컬럼별 필터 상태
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
// 모달 상태
const [showForm, setShowForm] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState<TaxInvoice | null>(null);
const [editMode, setEditMode] = useState(false);
// 확인 다이얼로그 상태
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
type: "delete" | "issue" | "cancel";
invoice: TaxInvoice | null;
}>({
open: false,
type: "delete",
invoice: null,
});
// 데이터 로드
const loadData = useCallback(async () => {
setLoading(true);
try {
// 컬럼 필터를 API 파라미터에 추가
const apiFilters: TaxInvoiceListParams = {
...filters,
invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined,
invoice_status: columnFilters.invoice_status,
cost_type: columnFilters.cost_type as CostType | undefined,
search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined,
};
const response = await getTaxInvoiceList(apiFilters);
if (response.success) {
let data = response.data;
// 클라이언트 사이드 정렬 적용
if (sortConfig) {
data = [...data].sort((a, b) => {
const aVal = a[sortConfig.key as keyof TaxInvoice];
const bVal = b[sortConfig.key as keyof TaxInvoice];
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
// 숫자 비교
if (typeof aVal === "number" && typeof bVal === "number") {
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
}
// 문자열 비교
const strA = String(aVal).toLowerCase();
const strB = String(bVal).toLowerCase();
if (sortConfig.direction === "asc") {
return strA.localeCompare(strB, "ko");
}
return strB.localeCompare(strA, "ko");
});
}
// 클라이언트 사이드 필터 적용 (날짜 필터)
if (columnFilters.invoice_date) {
data = data.filter((item) =>
item.invoice_date?.includes(columnFilters.invoice_date)
);
}
setInvoices(data);
setPagination(response.pagination);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
}, [filters, sortConfig, columnFilters, searchText]);
useEffect(() => {
loadData();
}, [loadData]);
// 정렬 핸들러
const handleSort = (columnKey: string) => {
setSortConfig((prev) => {
if (prev?.key === columnKey) {
// 같은 컬럼 클릭: asc -> desc -> null 순환
if (prev.direction === "asc") return { key: columnKey, direction: "desc" };
return null;
}
// 새 컬럼: asc로 시작
return { key: columnKey, direction: "asc" };
});
};
// 컬럼 필터 핸들러
const handleColumnFilter = (columnKey: string, value: string) => {
setColumnFilters((prev) => {
if (!value) {
const { [columnKey]: _, ...rest } = prev;
return rest;
}
return { ...prev, [columnKey]: value };
});
setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로
};
// 필터 초기화
const clearColumnFilter = (columnKey: string) => {
setColumnFilters((prev) => {
const { [columnKey]: _, ...rest } = prev;
return rest;
});
setActiveFilterColumn(null);
};
// 모든 필터 초기화
const clearAllFilters = () => {
setColumnFilters({});
setSortConfig(null);
setSearchText("");
setFilters({ page: 1, pageSize: 20 });
};
// 정렬 아이콘 렌더링
const renderSortIcon = (columnKey: string) => {
if (sortConfig?.key !== columnKey) {
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-30" />;
}
return sortConfig.direction === "asc"
? <ArrowUp className="ml-1 h-3 w-3 text-primary" />
: <ArrowDown className="ml-1 h-3 w-3 text-primary" />;
};
// 검색
const handleSearch = () => {
setFilters((prev) => ({ ...prev, search: searchText, page: 1 }));
};
// 필터 변경
const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => {
setFilters((prev) => ({
...prev,
[key]: value === "all" ? undefined : value,
page: 1,
}));
};
// 새 세금계산서
const handleNew = () => {
setSelectedInvoice(null);
setEditMode(false);
setShowForm(true);
};
// 상세 보기
const handleView = (invoice: TaxInvoice) => {
setSelectedInvoice(invoice);
setShowDetail(true);
};
// 수정
const handleEdit = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다.");
return;
}
setSelectedInvoice(invoice);
setEditMode(true);
setShowForm(true);
};
// 삭제 확인
const handleDeleteConfirm = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다.");
return;
}
setConfirmDialog({ open: true, type: "delete", invoice });
};
// 발행 확인
const handleIssueConfirm = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다.");
return;
}
setConfirmDialog({ open: true, type: "issue", invoice });
};
// 취소 확인
const handleCancelConfirm = (invoice: TaxInvoice) => {
if (!["draft", "issued"].includes(invoice.invoice_status)) {
toast.warning("취소할 수 없는 상태입니다.");
return;
}
setConfirmDialog({ open: true, type: "cancel", invoice });
};
// 확인 다이얼로그 실행
const handleConfirmAction = async () => {
const { type, invoice } = confirmDialog;
if (!invoice) return;
try {
if (type === "delete") {
const response = await deleteTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 삭제되었습니다.");
loadData();
}
} else if (type === "issue") {
const response = await issueTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 발행되었습니다.");
loadData();
}
} else if (type === "cancel") {
const response = await cancelTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 취소되었습니다.");
loadData();
}
}
} catch (error: any) {
toast.error("작업 실패", { description: error.message });
} finally {
setConfirmDialog({ open: false, type: "delete", invoice: null });
}
};
// 폼 저장 완료
const handleFormSave = () => {
setShowForm(false);
setSelectedInvoice(null);
loadData();
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 날짜 포맷
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), "yyyy-MM-dd", { locale: ko });
} catch {
return dateString;
}
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"> </h1>
<Button onClick={handleNew}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 필터 영역 */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap items-end gap-4">
{/* 검색 */}
<div className="min-w-[200px] flex-1">
<Label className="text-xs"></Label>
<div className="flex gap-2">
<Input
placeholder="계산서번호, 거래처명 검색"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="h-9"
/>
<Button variant="outline" size="sm" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* 유형 필터 */}
<div className="w-[120px]">
<Label className="text-xs"></Label>
<Select
value={filters.invoice_type || "all"}
onValueChange={(v) => handleFilterChange("invoice_type", v as "sales" | "purchase")}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sales"></SelectItem>
<SelectItem value="purchase"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 상태 필터 */}
<div className="w-[120px]">
<Label className="text-xs"></Label>
<Select
value={filters.invoice_status || "all"}
onValueChange={(v) => handleFilterChange("invoice_status", v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft"></SelectItem>
<SelectItem value="issued"></SelectItem>
<SelectItem value="sent"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 새로고침 */}
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
{/* 필터 초기화 */}
{(Object.keys(columnFilters).length > 0 || sortConfig) && (
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="text-muted-foreground">
<X className="mr-1 h-4 w-4" />
</Button>
)}
</div>
{/* 활성 필터 표시 */}
{Object.keys(columnFilters).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(columnFilters).map(([key, value]) => {
const column = columns.find((c) => c.key === key);
let displayValue = value;
if (column?.filterOptions) {
displayValue = column.filterOptions.find((o) => o.value === value)?.label || value;
}
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{column?.label}: {displayValue}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => clearColumnFilter(key)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
);
})}
</div>
)}
</CardContent>
</Card>
{/* 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.key}
className={`
${column.width ? `w-[${column.width}]` : ""}
${column.align === "center" ? "text-center" : ""}
${column.align === "right" ? "text-right" : ""}
`}
style={{ width: column.width }}
>
<div className={`flex items-center gap-1 ${column.align === "right" ? "justify-end" : column.align === "center" ? "justify-center" : ""}`}>
{/* 컬럼 필터 (filterable인 경우) */}
{column.filterable && (
<Popover
open={activeFilterColumn === column.key}
onOpenChange={(open) => setActiveFilterColumn(open ? column.key : null)}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 p-0 ${columnFilters[column.key] ? "text-primary" : "text-muted-foreground opacity-50 hover:opacity-100"}`}
>
<Filter className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{column.label} </div>
{column.filterType === "select" ? (
<Select
value={columnFilters[column.key] || ""}
onValueChange={(v) => {
handleColumnFilter(column.key, v);
setActiveFilterColumn(null);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{column.filterOptions?.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder={`${column.label} 검색...`}
value={columnFilters[column.key] || ""}
onChange={(e) => handleColumnFilter(column.key, e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)}
className="h-8 text-xs"
autoFocus
/>
)}
{columnFilters[column.key] && (
<Button
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
onClick={() => clearColumnFilter(column.key)}
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
{/* 컬럼 라벨 + 정렬 */}
{column.sortable ? (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 font-medium hover:bg-transparent"
onClick={() => handleSort(column.key)}
>
{column.label}
{renderSortIcon(column.key)}
</Button>
) : (
<span>{column.label}</span>
)}
</div>
</TableHead>
))}
<TableHead className="w-[150px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
...
</TableCell>
</TableRow>
) : invoices.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
.
</TableCell>
</TableRow>
) : (
invoices.map((invoice) => (
<TableRow key={invoice.id} className="cursor-pointer hover:bg-muted/50">
<TableCell className="font-mono text-sm">{invoice.invoice_number}</TableCell>
<TableCell>
<Badge variant={invoice.invoice_type === "sales" ? "default" : "secondary"}>
{typeLabels[invoice.invoice_type]}
</Badge>
</TableCell>
<TableCell>
{invoice.cost_type ? (
<Badge variant="outline" className="text-xs">
{costTypeLabels[invoice.cost_type as CostType]}
</Badge>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
{statusLabels[invoice.invoice_status]}
</Badge>
</TableCell>
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
<TableCell className="max-w-[200px] truncate">
{invoice.buyer_name || "-"}
</TableCell>
<TableCell className="text-center">
{invoice.attachments && invoice.attachments.length > 0 ? (
<div className="flex items-center justify-center gap-1">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{invoice.attachments.length}
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(invoice.supply_amount)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(invoice.tax_amount)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatAmount(invoice.total_amount)}
</TableCell>
<TableCell>
<div className="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleView(invoice)}
title="상세보기"
>
<Eye className="h-4 w-4" />
</Button>
{invoice.invoice_status === "draft" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(invoice)}
title="수정"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleIssueConfirm(invoice)}
title="발행"
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleDeleteConfirm(invoice)}
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
{invoice.invoice_status === "issued" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-orange-600"
onClick={() => handleCancelConfirm(invoice)}
title="취소"
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 페이지네이션 */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{pagination.total} {(pagination.page - 1) * pagination.pageSize + 1}-
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
>
</Button>
</div>
</div>
)}
{/* 세금계산서 작성/수정 폼 */}
{showForm && (
<TaxInvoiceForm
open={showForm}
onClose={() => setShowForm(false)}
onSave={handleFormSave}
invoice={editMode ? selectedInvoice : null}
/>
)}
{/* 세금계산서 상세 */}
{showDetail && selectedInvoice && (
<TaxInvoiceDetail
open={showDetail}
onClose={() => setShowDetail(false)}
invoiceId={selectedInvoice.id}
/>
)}
{/* 확인 다이얼로그 */}
<Dialog
open={confirmDialog.open}
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{confirmDialog.type === "delete" && "세금계산서 삭제"}
{confirmDialog.type === "issue" && "세금계산서 발행"}
{confirmDialog.type === "cancel" && "세금계산서 취소"}
</DialogTitle>
<DialogDescription>
{confirmDialog.type === "delete" &&
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
{confirmDialog.type === "issue" &&
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
>
</Button>
<Button
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
onClick={handleConfirmAction}
>
{confirmDialog.type === "delete" && "삭제"}
{confirmDialog.type === "issue" && "발행"}
{confirmDialog.type === "cancel" && "취소 처리"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

@ -0,0 +1,290 @@
/**
* API
*/
import { apiClient } from "./client";
// 비용 유형
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
// 비용 유형 라벨
export const costTypeLabels: Record<CostType, string> = {
purchase: "구매",
installation: "설치",
repair: "수리",
maintenance: "유지보수",
disposal: "폐기",
other: "기타",
};
// 세금계산서 타입
export interface TaxInvoice {
id: string;
company_code: string;
invoice_number: string;
invoice_type: "sales" | "purchase";
invoice_status: "draft" | "issued" | "sent" | "cancelled";
supplier_business_no: string;
supplier_name: string;
supplier_ceo_name: string;
supplier_address: string;
supplier_business_type: string;
supplier_business_item: string;
buyer_business_no: string;
buyer_name: string;
buyer_ceo_name: string;
buyer_address: string;
buyer_email: string;
supply_amount: number;
tax_amount: number;
total_amount: number;
invoice_date: string;
issue_date: string | null;
remarks: string;
order_id: string | null;
customer_id: string | null;
attachments: TaxInvoiceAttachment[] | null;
cost_type: CostType | null; // 비용 유형
created_date: string;
updated_date: string;
writer: string;
}
// 첨부파일 타입
export interface TaxInvoiceAttachment {
id: string;
file_name: string;
file_path: string;
file_size: number;
file_type: string;
uploaded_at: string;
uploaded_by: string;
}
// 세금계산서 품목 타입
export interface TaxInvoiceItem {
id: string;
tax_invoice_id: string;
company_code: string;
item_seq: number;
item_date: string;
item_name: string;
item_spec: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks: string;
}
// 생성 DTO
export interface CreateTaxInvoiceDto {
invoice_type: "sales" | "purchase";
supplier_business_no?: string;
supplier_name?: string;
supplier_ceo_name?: string;
supplier_address?: string;
supplier_business_type?: string;
supplier_business_item?: string;
buyer_business_no?: string;
buyer_name?: string;
buyer_ceo_name?: string;
buyer_address?: string;
buyer_email?: string;
supply_amount: number;
tax_amount: number;
total_amount: number;
invoice_date: string;
remarks?: string;
order_id?: string;
customer_id?: string;
items?: CreateTaxInvoiceItemDto[];
attachments?: TaxInvoiceAttachment[];
cost_type?: CostType; // 비용 유형
}
// 품목 생성 DTO
export interface CreateTaxInvoiceItemDto {
item_date?: string;
item_name: string;
item_spec?: string;
quantity: number;
unit_price: number;
supply_amount: number;
tax_amount: number;
remarks?: string;
}
// 목록 조회 파라미터
export interface TaxInvoiceListParams {
page?: number;
pageSize?: number;
invoice_type?: "sales" | "purchase";
invoice_status?: string;
start_date?: string;
end_date?: string;
search?: string;
buyer_name?: string;
cost_type?: CostType; // 비용 유형 필터
}
// 목록 응답
export interface TaxInvoiceListResponse {
success: boolean;
data: TaxInvoice[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
// 상세 응답
export interface TaxInvoiceDetailResponse {
success: boolean;
data: {
invoice: TaxInvoice;
items: TaxInvoiceItem[];
};
}
// 월별 통계 응답
export interface TaxInvoiceMonthlyStatsResponse {
success: boolean;
data: {
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
};
period: { year: number; month: number };
}
/**
*
*/
export async function getTaxInvoiceList(
params?: TaxInvoiceListParams
): Promise<TaxInvoiceListResponse> {
const response = await apiClient.get("/tax-invoice", { params });
return response.data;
}
/**
*
*/
export async function getTaxInvoiceById(id: string): Promise<TaxInvoiceDetailResponse> {
const response = await apiClient.get(`/tax-invoice/${id}`);
return response.data;
}
/**
*
*/
export async function createTaxInvoice(
data: CreateTaxInvoiceDto
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
const response = await apiClient.post("/tax-invoice", data);
return response.data;
}
/**
*
*/
export async function updateTaxInvoice(
id: string,
data: Partial<CreateTaxInvoiceDto>
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
const response = await apiClient.put(`/tax-invoice/${id}`, data);
return response.data;
}
/**
*
*/
export async function deleteTaxInvoice(
id: string
): Promise<{ success: boolean; message: string }> {
const response = await apiClient.delete(`/tax-invoice/${id}`);
return response.data;
}
/**
*
*/
export async function issueTaxInvoice(
id: string
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
const response = await apiClient.post(`/tax-invoice/${id}/issue`);
return response.data;
}
/**
*
*/
export async function cancelTaxInvoice(
id: string,
reason?: string
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason });
return response.data;
}
/**
*
*/
export async function getTaxInvoiceMonthlyStats(
year?: number,
month?: number
): Promise<TaxInvoiceMonthlyStatsResponse> {
const params: Record<string, number> = {};
if (year) params.year = year;
if (month) params.month = month;
const response = await apiClient.get("/tax-invoice/stats/monthly", { params });
return response.data;
}
// 비용 유형별 통계 응답
export interface CostTypeStatsResponse {
success: boolean;
data: {
by_cost_type: Array<{
cost_type: CostType | null;
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}>;
by_month: Array<{
year_month: string;
cost_type: CostType | null;
count: number;
total_amount: number;
}>;
summary: {
total_count: number;
total_amount: number;
purchase_amount: number;
installation_amount: number;
repair_amount: number;
maintenance_amount: number;
disposal_amount: number;
other_amount: number;
};
};
period: { year?: number; month?: number };
}
/**
*
*/
export async function getCostTypeStats(
year?: number,
month?: number
): Promise<CostTypeStatsResponse> {
const params: Record<string, number> = {};
if (year) params.year = year;
if (month) params.month = month;
const response = await apiClient.get("/tax-invoice/stats/cost-type", { params });
return response.data;
}

View File

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

View File

@ -0,0 +1,48 @@
"use client";
/**
* ( )
*/
import React from "react";
import { TaxInvoiceList } from "@/components/tax-invoice";
import { TaxInvoiceListConfig } from "./types";
interface TaxInvoiceListComponentProps {
config?: TaxInvoiceListConfig;
componentId?: string;
isEditMode?: boolean;
}
export function TaxInvoiceListComponent({
config,
componentId,
isEditMode,
}: TaxInvoiceListComponentProps) {
// 편집 모드에서는 플레이스홀더 표시
if (isEditMode) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-4xl">📄</div>
<p className="text-muted-foreground text-sm font-medium"> </p>
<p className="text-muted-foreground text-xs">
{config?.title || "세금계산서 관리"}
</p>
</div>
</div>
);
}
return (
<div className="h-full w-full" style={{ height: config?.height || "auto" }}>
<TaxInvoiceList />
</div>
);
}
// 래퍼 컴포넌트 (레지스트리 호환용)
export function TaxInvoiceListWrapper(props: any) {
return <TaxInvoiceListComponent {...props} />;
}

View File

@ -0,0 +1,166 @@
"use client";
/**
*
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
interface TaxInvoiceListConfigPanelProps {
config: TaxInvoiceListConfig;
onChange: (config: TaxInvoiceListConfig) => void;
}
export function TaxInvoiceListConfigPanel({
config,
onChange,
}: TaxInvoiceListConfigPanelProps) {
const currentConfig = { ...defaultTaxInvoiceListConfig, ...config };
const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => {
onChange({ ...currentConfig, [key]: value });
};
return (
<div className="space-y-4">
{/* 기본 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div>
<Label className="text-xs"></Label>
<Input
value={currentConfig.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="세금계산서 관리"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.showHeader}
onCheckedChange={(checked) => handleChange("showHeader", checked)}
/>
</div>
</div>
{/* 기본 필터 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div>
<Label className="text-xs"> </Label>
<Select
value={currentConfig.defaultInvoiceType}
onValueChange={(v) => handleChange("defaultInvoiceType", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sales"></SelectItem>
<SelectItem value="purchase"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={currentConfig.defaultStatus}
onValueChange={(v) => handleChange("defaultStatus", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft"></SelectItem>
<SelectItem value="issued"></SelectItem>
<SelectItem value="sent"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={String(currentConfig.pageSize)}
onValueChange={(v) => handleChange("pageSize", parseInt(v))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 권한 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.canCreate}
onCheckedChange={(checked) => handleChange("canCreate", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.canEdit}
onCheckedChange={(checked) => handleChange("canEdit", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.canDelete}
onCheckedChange={(checked) => handleChange("canDelete", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.canIssue}
onCheckedChange={(checked) => handleChange("canIssue", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.canCancel}
onCheckedChange={(checked) => handleChange("canCancel", checked)}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { TaxInvoiceListDefinition } from "./index";
import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent";
/**
*
*
*/
export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TaxInvoiceListDefinition;
render(): React.ReactElement {
return <TaxInvoiceListComponent {...this.props} />;
}
}
// 자동 등록 실행
TaxInvoiceListRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
TaxInvoiceListRenderer.registerSelf();
} catch (error) {
console.error("TaxInvoiceList 강제 등록 실패:", error);
}
}, 1000);
}

View File

@ -0,0 +1,37 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
/**
*
* CRUD, ,
*/
export const TaxInvoiceListDefinition = createComponentDefinition({
id: "tax-invoice-list",
name: "세금계산서 목록",
nameEng: "Tax Invoice List",
description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: TaxInvoiceListWrapper,
defaultConfig: defaultTaxInvoiceListConfig,
defaultSize: { width: 1200, height: 700 },
configPanel: TaxInvoiceListConfigPanel,
icon: "FileText",
tags: ["세금계산서", "매출", "매입", "발행", "인보이스"],
version: "1.0.0",
author: "개발팀",
documentation: "",
});
// 타입 내보내기
export type { TaxInvoiceListConfig } from "./types";
export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer";

View File

@ -0,0 +1,41 @@
/**
*
*/
export interface TaxInvoiceListConfig {
// 기본 설정
title?: string;
showHeader?: boolean;
// 필터 설정
defaultInvoiceType?: "all" | "sales" | "purchase";
defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled";
// 페이지네이션
pageSize?: number;
// 권한 설정
canCreate?: boolean;
canEdit?: boolean;
canDelete?: boolean;
canIssue?: boolean;
canCancel?: boolean;
// 스타일
height?: string | number;
}
export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = {
title: "세금계산서 관리",
showHeader: true,
defaultInvoiceType: "all",
defaultStatus: "all",
pageSize: 20,
canCreate: true,
canEdit: true,
canDelete: true,
canIssue: true,
canCancel: true,
height: "auto",
};