세금계산서 발행 완료
This commit is contained in:
parent
f04a3e3505
commit
ab1308efe8
|
|
@ -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); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* 세금계산서 컨트롤러
|
||||
* 세금계산서 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,
|
||||
} = 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,
|
||||
});
|
||||
|
||||
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 || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 세금계산서 라우터
|
||||
* /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("/: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;
|
||||
|
||||
|
|
@ -0,0 +1,612 @@
|
|||
/**
|
||||
* 세금계산서 서비스
|
||||
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||
*/
|
||||
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 세금계산서 타입 정의
|
||||
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;
|
||||
|
||||
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[]; // 첨부파일
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
} = 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++;
|
||||
}
|
||||
|
||||
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, 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
|
||||
) 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,
|
||||
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,
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 보기 컴포넌트
|
||||
* PDF 출력 및 첨부파일 다운로드 기능 포함
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import {
|
||||
Printer,
|
||||
Download,
|
||||
FileText,
|
||||
Image,
|
||||
File,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getTaxInvoiceById,
|
||||
TaxInvoice,
|
||||
TaxInvoiceItem,
|
||||
TaxInvoiceAttachment,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface TaxInvoiceDetailProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "임시저장",
|
||||
issued: "발행완료",
|
||||
sent: "전송완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-gray-100 text-gray-800",
|
||||
issued: "bg-green-100 text-green-800",
|
||||
sent: "bg-blue-100 text-blue-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
|
||||
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
|
||||
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open && invoiceId) {
|
||||
loadData();
|
||||
}
|
||||
}, [open, invoiceId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaxInvoiceById(invoiceId);
|
||||
if (response.success) {
|
||||
setInvoice(response.data.invoice);
|
||||
setItems(response.data.items);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
|
||||
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
|
||||
// objid가 숫자형이면 API를 통해 미리보기
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
// apiClient의 baseURL 사용
|
||||
const baseURL = apiClient.defaults.baseURL || "";
|
||||
return `${baseURL}/files/preview/${attachment.id}`;
|
||||
}
|
||||
return attachment.file_path;
|
||||
};
|
||||
|
||||
// 공통 인쇄용 HTML 생성 함수
|
||||
const generatePrintHtml = (autoPrint: boolean = false) => {
|
||||
if (!invoice) return "";
|
||||
|
||||
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
|
||||
const itemsHtml = items.map((item, index) => `
|
||||
<tr>
|
||||
<td style="text-align:center">${index + 1}</td>
|
||||
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
|
||||
<td>${item.item_name}</td>
|
||||
<td>${item.item_spec || "-"}</td>
|
||||
<td style="text-align:right">${item.quantity}</td>
|
||||
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>세금계산서_${invoice.invoice_number}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
||||
.header .invoice-number { font-size: 14px; color: #666; }
|
||||
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
|
||||
.status-draft { background: #f3f4f6; color: #374151; }
|
||||
.status-issued { background: #d1fae5; color: #065f46; }
|
||||
.status-sent { background: #dbeafe; color: #1e40af; }
|
||||
.status-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
|
||||
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||
.party-label { width: 80px; color: #666; }
|
||||
.party-value { flex: 1; }
|
||||
.items-section { margin-bottom: 30px; }
|
||||
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
.total-section { display: flex; justify-content: flex-end; }
|
||||
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
|
||||
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
|
||||
.total-row.grand .value { color: #1d4ed8; }
|
||||
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
|
||||
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
|
||||
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
|
||||
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.attachments ul { list-style: none; font-size: 12px; }
|
||||
.attachments li { padding: 4px 0; }
|
||||
@media print {
|
||||
body { padding: 15px; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>세금계산서 (${invoiceTypeText})</h1>
|
||||
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
|
||||
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<h3>공급자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">업태/종목</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
|
||||
</div>
|
||||
<div class="party">
|
||||
<h3>공급받는자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">이메일</span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<h3>품목 내역</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">No</th>
|
||||
<th style="width:80px">일자</th>
|
||||
<th>품목명</th>
|
||||
<th style="width:70px">규격</th>
|
||||
<th style="width:50px">수량</th>
|
||||
<th style="width:80px">단가</th>
|
||||
<th style="width:90px">공급가액</th>
|
||||
<th style="width:70px">세액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="total-section">
|
||||
<div class="total-box">
|
||||
<div class="total-row"><span>공급가액</span><span>${formatAmount(invoice.supply_amount)}원</span></div>
|
||||
<div class="total-row"><span>세액</span><span>${formatAmount(invoice.tax_amount)}원</span></div>
|
||||
<div class="total-row grand"><span>합계금액</span><span class="value">${formatAmount(invoice.total_amount)}원</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
|
||||
|
||||
${invoice.attachments && invoice.attachments.length > 0 ? `
|
||||
<div class="attachments">
|
||||
<h3>첨부파일 (${invoice.attachments.length}개)</h3>
|
||||
<ul>
|
||||
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="footer">
|
||||
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
|
||||
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
if (!invoice) return;
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
// PDF 다운로드 (인쇄 다이얼로그 사용)
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoice) return;
|
||||
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
|
||||
} catch (error: any) {
|
||||
console.error("PDF 생성 오류:", error);
|
||||
toast.error("PDF 생성 실패", { description: error.message });
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 아이콘
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
// 파일 다운로드 (인증 토큰 포함)
|
||||
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
|
||||
try {
|
||||
// objid가 숫자형이면 API를 통해 다운로드
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
const response = await apiClient.get(`/files/download/${attachment.id}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
// Blob으로 다운로드
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = attachment.file_name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// 직접 경로로 다운로드
|
||||
window.open(attachment.file_path, "_blank");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("파일 다운로드 실패", { description: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
|
||||
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
>
|
||||
{pdfLoading ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)]">
|
||||
<div className="p-6" ref={printRef}>
|
||||
<div className="invoice-container">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="mb-2 text-2xl font-bold">
|
||||
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
계산서번호: {invoice.invoice_number}
|
||||
</p>
|
||||
<Badge className={statusColors[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 공급자 / 공급받는자 정보 */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-6">
|
||||
{/* 공급자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.supplier_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.supplier_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.supplier_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">업태/종목</span>
|
||||
<span>
|
||||
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.supplier_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급받는자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급받는자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.buyer_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.buyer_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.buyer_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">이메일</span>
|
||||
<span>{invoice.buyer_email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.buyer_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold">품목 내역</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
|
||||
<TableCell>{item.item_name}</TableCell>
|
||||
<TableCell>{item.item_spec || "-"}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.unit_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.tax_amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
|
||||
품목 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.supply_amount)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.tax_amount)}원</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>합계금액</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(invoice.total_amount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
{invoice.remarks && (
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-2 font-semibold">비고</h3>
|
||||
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
|
||||
{invoice.remarks}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜 정보 */}
|
||||
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
|
||||
<span>작성일: {formatDate(invoice.invoice_date)}</span>
|
||||
{invoice.issue_date && <span>발행일: {formatDate(invoice.issue_date)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{invoice.attachments && invoice.attachments.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<Separator className="mb-4" />
|
||||
<h3 className="mb-3 font-semibold">첨부파일 ({invoice.attachments.length}개)</h3>
|
||||
|
||||
{/* 이미지 미리보기 */}
|
||||
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{invoice.attachments
|
||||
.filter((f) => f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
|
||||
>
|
||||
<img
|
||||
src={getFilePreviewUrl(file)}
|
||||
alt={file.file_name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="w-full p-2">
|
||||
<p className="truncate text-xs text-white">{file.file_name}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기타 파일 목록 */}
|
||||
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
|
||||
<div className="space-y-2">
|
||||
{invoice.attachments
|
||||
.filter((f) => !f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<span className="text-sm">{file.file_name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
"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,
|
||||
} 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,
|
||||
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-3 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>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.invoice_date}
|
||||
onChange={(e) => handleChange("invoice_date", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input
|
||||
value={formData.remarks || ""}
|
||||
onChange={(e) => handleChange("remarks", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">품목 내역</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(formData.items || []).map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.item_date || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_date", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_name || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_name", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="품목명"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_spec || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_spec", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.unit_price || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(
|
||||
index,
|
||||
"unit_price",
|
||||
parseFloat(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.supply_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.tax_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
disabled={(formData.items?.length || 0) <= 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(formData.supply_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2 text-lg font-bold">
|
||||
<span>합계</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(formData.total_amount || 0)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급자 탭 */}
|
||||
<TabsContent value="supplier" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_no || ""}
|
||||
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.supplier_name || ""}
|
||||
onChange={(e) => handleChange("supplier_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.supplier_ceo_name || ""}
|
||||
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">업태</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_type || ""}
|
||||
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="업태"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종목</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_item || ""}
|
||||
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="종목"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.supplier_address || ""}
|
||||
onChange={(e) => handleChange("supplier_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급받는자 탭 */}
|
||||
<TabsContent value="buyer" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.buyer_business_no || ""}
|
||||
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.buyer_name || ""}
|
||||
onChange={(e) => handleChange("buyer_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.buyer_ceo_name || ""}
|
||||
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.buyer_email || ""}
|
||||
onChange={(e) => handleChange("buyer_email", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.buyer_address || ""}
|
||||
onChange={(e) => handleChange("buyer_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 첨부파일 탭 */}
|
||||
<TabsContent value="attachments" className="space-y-4">
|
||||
{/* 업로드 영역 */}
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex cursor-pointer flex-col items-center gap-2"
|
||||
>
|
||||
<Upload className="text-muted-foreground h-8 w-8" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
PDF, 이미지, 문서 파일 (최대 10MB)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 목록 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">첨부된 파일 ({attachments.length}개)</Label>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.file_name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveAttachment(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
첨부된 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,818 @@
|
|||
"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,
|
||||
} 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: "80px" },
|
||||
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: [
|
||||
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
|
||||
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
|
||||
], width: "100px" },
|
||||
{ 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: "60px", 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,
|
||||
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={10} className="text-muted-foreground py-8 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : invoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} 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>
|
||||
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{invoice.buyer_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{invoice.attachments && invoice.attachments.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{invoice.attachments.length}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.tax_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">
|
||||
{formatAmount(invoice.total_amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleView(invoice)}
|
||||
title="상세보기"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{invoice.invoice_status === "draft" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(invoice)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleIssueConfirm(invoice)}
|
||||
title="발행"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDeleteConfirm(invoice)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.invoice_status === "issued" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-orange-600"
|
||||
onClick={() => handleCancelConfirm(invoice)}
|
||||
title="취소"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}-
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.total)}건
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 작성/수정 폼 */}
|
||||
{showForm && (
|
||||
<TaxInvoiceForm
|
||||
open={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSave={handleFormSave}
|
||||
invoice={editMode ? selectedInvoice : null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 상세 */}
|
||||
{showDetail && selectedInvoice && (
|
||||
<TaxInvoiceDetail
|
||||
open={showDetail}
|
||||
onClose={() => setShowDetail(false)}
|
||||
invoiceId={selectedInvoice.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={confirmDialog.open}
|
||||
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmDialog.type === "delete" && "세금계산서 삭제"}
|
||||
{confirmDialog.type === "issue" && "세금계산서 발행"}
|
||||
{confirmDialog.type === "cancel" && "세금계산서 취소"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmDialog.type === "delete" &&
|
||||
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
|
||||
{confirmDialog.type === "issue" &&
|
||||
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
|
||||
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
|
||||
onClick={handleConfirmAction}
|
||||
>
|
||||
{confirmDialog.type === "delete" && "삭제"}
|
||||
{confirmDialog.type === "issue" && "발행"}
|
||||
{confirmDialog.type === "cancel" && "취소 처리"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { TaxInvoiceList } from "./TaxInvoiceList";
|
||||
export { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||
export { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* 세금계산서 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 세금계산서 타입
|
||||
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;
|
||||
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[];
|
||||
}
|
||||
|
||||
// 품목 생성 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;
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
|||
// 🆕 범용 폼 모달 컴포넌트
|
||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||
|
||||
// 🆕 세금계산서 관리 컴포넌트
|
||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트 (레지스트리용 래퍼)
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { TaxInvoiceList } from "@/components/tax-invoice";
|
||||
import { TaxInvoiceListConfig } from "./types";
|
||||
|
||||
interface TaxInvoiceListComponentProps {
|
||||
config?: TaxInvoiceListConfig;
|
||||
componentId?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function TaxInvoiceListComponent({
|
||||
config,
|
||||
componentId,
|
||||
isEditMode,
|
||||
}: TaxInvoiceListComponentProps) {
|
||||
// 편집 모드에서는 플레이스홀더 표시
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-4xl">📄</div>
|
||||
<p className="text-muted-foreground text-sm font-medium">세금계산서 목록</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config?.title || "세금계산서 관리"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" style={{ height: config?.height || "auto" }}>
|
||||
<TaxInvoiceList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 래퍼 컴포넌트 (레지스트리 호환용)
|
||||
export function TaxInvoiceListWrapper(props: any) {
|
||||
return <TaxInvoiceListComponent {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 설정 패널
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||
|
||||
interface TaxInvoiceListConfigPanelProps {
|
||||
config: TaxInvoiceListConfig;
|
||||
onChange: (config: TaxInvoiceListConfig) => void;
|
||||
}
|
||||
|
||||
export function TaxInvoiceListConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: TaxInvoiceListConfigPanelProps) {
|
||||
const currentConfig = { ...defaultTaxInvoiceListConfig, ...config };
|
||||
|
||||
const handleChange = (key: keyof TaxInvoiceListConfig, value: any) => {
|
||||
onChange({ ...currentConfig, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
placeholder="세금계산서 관리"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">헤더 표시</Label>
|
||||
<Switch
|
||||
checked={currentConfig.showHeader}
|
||||
onCheckedChange={(checked) => handleChange("showHeader", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 필터 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본 필터</h4>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">기본 유형</Label>
|
||||
<Select
|
||||
value={currentConfig.defaultInvoiceType}
|
||||
onValueChange={(v) => handleChange("defaultInvoiceType", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">기본 상태</Label>
|
||||
<Select
|
||||
value={currentConfig.defaultStatus}
|
||||
onValueChange={(v) => handleChange("defaultStatus", v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="draft">임시저장</SelectItem>
|
||||
<SelectItem value="issued">발행완료</SelectItem>
|
||||
<SelectItem value="sent">전송완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">페이지당 항목 수</Label>
|
||||
<Select
|
||||
value={String(currentConfig.pageSize)}
|
||||
onValueChange={(v) => handleChange("pageSize", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 설정 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">권한 설정</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">생성 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canCreate}
|
||||
onCheckedChange={(checked) => handleChange("canCreate", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">수정 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canEdit}
|
||||
onCheckedChange={(checked) => handleChange("canEdit", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">삭제 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canDelete}
|
||||
onCheckedChange={(checked) => handleChange("canDelete", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">발행 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canIssue}
|
||||
onCheckedChange={(checked) => handleChange("canIssue", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">취소 가능</Label>
|
||||
<Switch
|
||||
checked={currentConfig.canCancel}
|
||||
onCheckedChange={(checked) => handleChange("canCancel", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { TaxInvoiceListDefinition } from "./index";
|
||||
import { TaxInvoiceListComponent } from "./TaxInvoiceListComponent";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class TaxInvoiceListRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = TaxInvoiceListDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <TaxInvoiceListComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
TaxInvoiceListRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
TaxInvoiceListRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("TaxInvoiceList 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||
import { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||
import { TaxInvoiceListConfig, defaultTaxInvoiceListConfig } from "./types";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트 정의
|
||||
* 세금계산서 CRUD, 발행, 취소 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const TaxInvoiceListDefinition = createComponentDefinition({
|
||||
id: "tax-invoice-list",
|
||||
name: "세금계산서 목록",
|
||||
nameEng: "Tax Invoice List",
|
||||
description: "세금계산서 목록 조회, 작성, 발행, 취소 기능을 제공하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: TaxInvoiceListWrapper,
|
||||
defaultConfig: defaultTaxInvoiceListConfig,
|
||||
defaultSize: { width: 1200, height: 700 },
|
||||
configPanel: TaxInvoiceListConfigPanel,
|
||||
icon: "FileText",
|
||||
tags: ["세금계산서", "매출", "매입", "발행", "인보이스"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { TaxInvoiceListConfig } from "./types";
|
||||
export { TaxInvoiceListWrapper } from "./TaxInvoiceListComponent";
|
||||
export { TaxInvoiceListConfigPanel } from "./TaxInvoiceListConfigPanel";
|
||||
export { TaxInvoiceListRenderer } from "./TaxInvoiceListRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* 세금계산서 목록 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface TaxInvoiceListConfig {
|
||||
// 기본 설정
|
||||
title?: string;
|
||||
showHeader?: boolean;
|
||||
|
||||
// 필터 설정
|
||||
defaultInvoiceType?: "all" | "sales" | "purchase";
|
||||
defaultStatus?: "all" | "draft" | "issued" | "sent" | "cancelled";
|
||||
|
||||
// 페이지네이션
|
||||
pageSize?: number;
|
||||
|
||||
// 권한 설정
|
||||
canCreate?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canIssue?: boolean;
|
||||
canCancel?: boolean;
|
||||
|
||||
// 스타일
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export const defaultTaxInvoiceListConfig: TaxInvoiceListConfig = {
|
||||
title: "세금계산서 관리",
|
||||
showHeader: true,
|
||||
defaultInvoiceType: "all",
|
||||
defaultStatus: "all",
|
||||
pageSize: 20,
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canIssue: true,
|
||||
canCancel: true,
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue