Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
7ac6bbc2c6
|
|
@ -8,6 +8,7 @@ import path from "path";
|
|||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
|
|
@ -74,6 +75,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"; // 임시 주석
|
||||
|
|
@ -168,6 +170,10 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
|
|
@ -240,6 +246,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); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* POST /api/table-categories/labels-by-codes
|
||||
*
|
||||
* Body:
|
||||
* - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||
*
|
||||
* Response:
|
||||
* - { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { valueCodes } = req.body;
|
||||
|
||||
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("카테고리 코드로 라벨 조회", {
|
||||
valueCodes,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
|
||||
valueCodes,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 세금계산서 컨트롤러
|
||||
* 세금계산서 API 엔드포인트 처리
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { TaxInvoiceService } from "../services/taxInvoiceService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TaxInvoiceController {
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
* GET /api/tax-invoice
|
||||
*/
|
||||
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
page = "1",
|
||||
pageSize = "20",
|
||||
invoice_type,
|
||||
invoice_status,
|
||||
start_date,
|
||||
end_date,
|
||||
search,
|
||||
buyer_name,
|
||||
cost_type,
|
||||
} = req.query;
|
||||
|
||||
const result = await TaxInvoiceService.getList(companyCode, {
|
||||
page: parseInt(page as string, 10),
|
||||
pageSize: parseInt(pageSize as string, 10),
|
||||
invoice_type: invoice_type as "sales" | "purchase" | undefined,
|
||||
invoice_status: invoice_status as string | undefined,
|
||||
start_date: start_date as string | undefined,
|
||||
end_date: end_date as string | undefined,
|
||||
search: search as string | undefined,
|
||||
buyer_name: buyer_name as string | undefined,
|
||||
cost_type: cost_type as any,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
totalPages: Math.ceil(result.total / result.pageSize),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
* GET /api/tax-invoice/:id
|
||||
*/
|
||||
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.getById(id, companyCode);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
* POST /api/tax-invoice
|
||||
*/
|
||||
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.invoice_type) {
|
||||
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (!data.invoice_date) {
|
||||
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (data.supply_amount === undefined || data.supply_amount === null) {
|
||||
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await TaxInvoiceService.create(data, companyCode, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
* PUT /api/tax-invoice/:id
|
||||
*/
|
||||
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 수정 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
* DELETE /api/tax-invoice/:id
|
||||
*/
|
||||
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.delete(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "세금계산서가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 삭제 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
* POST /api/tax-invoice/:id/issue
|
||||
*/
|
||||
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await TaxInvoiceService.issue(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 발행되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 발행 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
* POST /api/tax-invoice/:id/cancel
|
||||
*/
|
||||
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 취소 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/tax-invoice/stats/monthly
|
||||
*/
|
||||
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = req.query;
|
||||
const now = new Date();
|
||||
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
|
||||
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
|
||||
|
||||
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
period: { year: targetYear, month: targetMonth },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("월별 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
* GET /api/tax-invoice/stats/cost-type
|
||||
*/
|
||||
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = req.query;
|
||||
const targetYear = year ? parseInt(year as string, 10) : undefined;
|
||||
const targetMonth = month ? parseInt(month as string, 10) : undefined;
|
||||
|
||||
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
period: { year: targetYear, month: targetMonth },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("비용 유형별 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,16 +54,17 @@ export const authenticateToken = (
|
|||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||
|
||||
// 토큰 만료 에러인지 확인
|
||||
const isTokenExpired = errorMessage.includes("만료");
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
details:
|
||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
getCategoryLabelsByCodes,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
|||
// 카테고리 값 순서 변경
|
||||
router.post("/values/reorder", reorderCategoryValues);
|
||||
|
||||
// 카테고리 코드로 라벨 조회
|
||||
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 세금계산서 라우터
|
||||
* /api/tax-invoice 경로 처리
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 목록 조회
|
||||
router.get("/", TaxInvoiceController.getList);
|
||||
|
||||
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
||||
|
||||
// 비용 유형별 통계
|
||||
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
||||
|
||||
// 상세 조회
|
||||
router.get("/:id", TaxInvoiceController.getById);
|
||||
|
||||
// 생성
|
||||
router.post("/", TaxInvoiceController.create);
|
||||
|
||||
// 수정
|
||||
router.put("/:id", TaxInvoiceController.update);
|
||||
|
||||
// 삭제
|
||||
router.delete("/:id", TaxInvoiceController.delete);
|
||||
|
||||
// 발행
|
||||
router.post("/:id/issue", TaxInvoiceController.issue);
|
||||
|
||||
// 취소
|
||||
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -907,8 +907,27 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
||||
|
||||
const invalidColumns: string[] = [];
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key]) => {
|
||||
if (validColumnNames.has(key)) {
|
||||
return true;
|
||||
}
|
||||
invalidColumns.push(key);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
const columns = Object.keys(filteredData);
|
||||
const values = Object.values(filteredData);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||
|
||||
|
|
@ -951,9 +970,28 @@ class DataService {
|
|||
|
||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||
const relationInfo = data._relationInfo;
|
||||
const cleanData = { ...data };
|
||||
let cleanData = { ...data };
|
||||
delete cleanData._relationInfo;
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
||||
|
||||
const invalidColumns: string[] = [];
|
||||
cleanData = Object.fromEntries(
|
||||
Object.entries(cleanData).filter(([key]) => {
|
||||
if (validColumnNames.has(key)) {
|
||||
return true;
|
||||
}
|
||||
invalidColumns.push(key);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
|
|
|
|||
|
|
@ -506,6 +506,24 @@ export class DynamicFormService {
|
|||
// 헤더 + 품목을 병합
|
||||
const rawMergedData = { ...dataToInsert, ...item };
|
||||
|
||||
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
||||
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
||||
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
|
||||
const isExistingRecord = rawMergedData._existingRecord === true;
|
||||
|
||||
if (!isExistingRecord) {
|
||||
// 새 레코드: id 제거하여 새 UUID 자동 생성
|
||||
const oldId = rawMergedData.id;
|
||||
delete rawMergedData.id;
|
||||
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
|
||||
} else {
|
||||
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
|
||||
}
|
||||
|
||||
// 메타 플래그 제거
|
||||
delete rawMergedData._isNewItem;
|
||||
delete rawMergedData._existingRecord;
|
||||
|
||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||
const mergedData: Record<string, any> = {};
|
||||
|
|
@ -1160,7 +1178,15 @@ export class DynamicFormService {
|
|||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||||
console.log("📊 SQL 파라미터:", [id]);
|
||||
|
||||
const result = await query<any>(deleteQuery, [id]);
|
||||
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
|
||||
const result = await transaction(async (client) => {
|
||||
// 이력 트리거에서 사용할 사용자 정보 설정
|
||||
if (userId) {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
}
|
||||
const res = await client.query(deleteQuery, [id]);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type NodeType =
|
|||
| "restAPISource"
|
||||
| "condition"
|
||||
| "dataTransform"
|
||||
| "aggregate"
|
||||
| "insertAction"
|
||||
| "updateAction"
|
||||
| "deleteAction"
|
||||
|
|
@ -528,6 +529,9 @@ export class NodeFlowExecutionService {
|
|||
case "dataTransform":
|
||||
return this.executeDataTransform(node, inputData, context);
|
||||
|
||||
case "aggregate":
|
||||
return this.executeAggregate(node, inputData, context);
|
||||
|
||||
case "insertAction":
|
||||
return this.executeInsertAction(node, inputData, context, client);
|
||||
|
||||
|
|
@ -830,11 +834,18 @@ export class NodeFlowExecutionService {
|
|||
|
||||
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
|
||||
|
||||
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
|
||||
|
||||
const result = await query(sql, whereResult.values);
|
||||
|
||||
logger.info(
|
||||
`📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
// 디버깅: 조회된 데이터 샘플 출력
|
||||
if (result.length > 0) {
|
||||
logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1355,57 +1366,64 @@ export class NodeFlowExecutionService {
|
|||
let updatedCount = 0;
|
||||
const updatedDataArray: any[] = [];
|
||||
|
||||
// 🆕 table-all 모드: 단일 SQL로 일괄 업데이트
|
||||
// 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영)
|
||||
if (context.currentNodeDataSourceType === "table-all") {
|
||||
console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작");
|
||||
console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)");
|
||||
|
||||
// 첫 번째 데이터를 참조하여 SET 절 생성
|
||||
const firstData = dataArray[0];
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
// 🔥 각 그룹(데이터)별로 UPDATE 실행
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const data = dataArray[i];
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: firstData[mapping.sourceField];
|
||||
console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`);
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(
|
||||
whereConditions,
|
||||
data,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
values.push(...whereResult.values);
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(
|
||||
whereConditions,
|
||||
firstData,
|
||||
paramIndex
|
||||
);
|
||||
const sql = `
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
${whereResult.clause}
|
||||
`;
|
||||
|
||||
values.push(...whereResult.values);
|
||||
console.log("📝 실행할 SQL:", sql);
|
||||
console.log("📊 바인딩 값:", values);
|
||||
|
||||
const sql = `
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
${whereResult.clause}
|
||||
`;
|
||||
|
||||
console.log("📝 실행할 SQL (일괄 처리):", sql);
|
||||
console.log("📊 바인딩 값:", values);
|
||||
|
||||
const result = await txClient.query(sql, values);
|
||||
updatedCount = result.rowCount || 0;
|
||||
const result = await txClient.query(sql, values);
|
||||
const rowCount = result.rowCount || 0;
|
||||
updatedCount += rowCount;
|
||||
|
||||
console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}건`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}건`
|
||||
`✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, 총 ${updatedCount}건`
|
||||
);
|
||||
|
||||
// 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음)
|
||||
|
|
@ -3197,4 +3215,168 @@ export class NodeFlowExecutionService {
|
|||
"upsertAction",
|
||||
].includes(nodeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등)
|
||||
*/
|
||||
private static async executeAggregate(
|
||||
node: FlowNode,
|
||||
inputData: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data;
|
||||
|
||||
logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`);
|
||||
|
||||
// 입력 데이터가 없으면 빈 배열 반환
|
||||
if (!inputData || !Array.isArray(inputData) || inputData.length === 0) {
|
||||
logger.warn("⚠️ 집계할 입력 데이터가 없습니다.");
|
||||
logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`📥 입력 데이터: ${inputData.length}건`);
|
||||
logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`);
|
||||
logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`);
|
||||
logger.info(`📊 집계 연산: ${aggregations.length}개`);
|
||||
|
||||
// 그룹화 수행
|
||||
const groups = new Map<string, any[]>();
|
||||
|
||||
for (const row of inputData) {
|
||||
// 그룹 키 생성
|
||||
const groupKey = groupByFields.length > 0
|
||||
? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||")
|
||||
: "__ALL__";
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`📊 그룹 수: ${groups.size}개`);
|
||||
|
||||
// 디버깅: 각 그룹의 데이터 출력
|
||||
for (const [groupKey, groupRows] of groups) {
|
||||
logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`);
|
||||
}
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const results: any[] = [];
|
||||
|
||||
for (const [groupKey, groupRows] of groups) {
|
||||
const resultRow: any = {};
|
||||
|
||||
// 그룹 기준 필드값 추가
|
||||
if (groupByFields.length > 0) {
|
||||
const keyValues = groupKey.split("|||");
|
||||
groupByFields.forEach((field: any, idx: number) => {
|
||||
resultRow[field.field] = keyValues[idx];
|
||||
});
|
||||
}
|
||||
|
||||
// 각 집계 연산 수행
|
||||
for (const agg of aggregations) {
|
||||
const { sourceField, function: aggFunc, outputField } = agg;
|
||||
|
||||
if (!outputField) continue;
|
||||
|
||||
let aggregatedValue: any;
|
||||
|
||||
switch (aggFunc) {
|
||||
case "SUM":
|
||||
aggregatedValue = groupRows.reduce((sum: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
break;
|
||||
|
||||
case "COUNT":
|
||||
aggregatedValue = groupRows.length;
|
||||
break;
|
||||
|
||||
case "AVG":
|
||||
const sum = groupRows.reduce((acc: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return acc + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0;
|
||||
break;
|
||||
|
||||
case "MIN":
|
||||
aggregatedValue = groupRows.reduce((min: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return min;
|
||||
return min === null ? val : Math.min(min, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "MAX":
|
||||
aggregatedValue = groupRows.reduce((max: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return max;
|
||||
return max === null ? val : Math.max(max, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "FIRST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null;
|
||||
break;
|
||||
|
||||
case "LAST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null;
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`);
|
||||
aggregatedValue = null;
|
||||
}
|
||||
|
||||
resultRow[outputField] = aggregatedValue;
|
||||
logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`);
|
||||
}
|
||||
|
||||
results.push(resultRow);
|
||||
}
|
||||
|
||||
// HAVING 조건 적용 (집계 후 필터링)
|
||||
let filteredResults = results;
|
||||
if (havingConditions && havingConditions.length > 0) {
|
||||
filteredResults = results.filter((row) => {
|
||||
return havingConditions.every((condition: any) => {
|
||||
const fieldValue = row[condition.field];
|
||||
const compareValue = parseFloat(condition.value);
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
return fieldValue === compareValue;
|
||||
case "!=":
|
||||
return fieldValue !== compareValue;
|
||||
case ">":
|
||||
return fieldValue > compareValue;
|
||||
case ">=":
|
||||
return fieldValue >= compareValue;
|
||||
case "<":
|
||||
return fieldValue < compareValue;
|
||||
case "<=":
|
||||
return fieldValue <= compareValue;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`);
|
||||
}
|
||||
|
||||
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
|
||||
|
||||
// 결과 샘플 출력
|
||||
if (filteredResults.length > 0) {
|
||||
logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2));
|
||||
}
|
||||
|
||||
return filteredResults;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,70 @@ class TableCategoryValueService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* @param valueCodes - 카테고리 코드 배열
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelsByCodes(
|
||||
valueCodes: string[],
|
||||
companyCode: string
|
||||
): Promise<Record<string, string>> {
|
||||
try {
|
||||
if (!valueCodes || valueCodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 동적으로 파라미터 플레이스홀더 생성
|
||||
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
`;
|
||||
params = valueCodes;
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
||||
`;
|
||||
params = [...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
||||
|
||||
return labels;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,784 @@
|
|||
/**
|
||||
* 세금계산서 서비스
|
||||
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||
*/
|
||||
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 비용 유형 타입
|
||||
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||
|
||||
// 세금계산서 타입 정의
|
||||
export interface TaxInvoice {
|
||||
id: string;
|
||||
company_code: string;
|
||||
invoice_number: string;
|
||||
invoice_type: "sales" | "purchase"; // 매출/매입
|
||||
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||
|
||||
// 공급자 정보
|
||||
supplier_business_no: string;
|
||||
supplier_name: string;
|
||||
supplier_ceo_name: string;
|
||||
supplier_address: string;
|
||||
supplier_business_type: string;
|
||||
supplier_business_item: string;
|
||||
|
||||
// 공급받는자 정보
|
||||
buyer_business_no: string;
|
||||
buyer_name: string;
|
||||
buyer_ceo_name: string;
|
||||
buyer_address: string;
|
||||
buyer_email: string;
|
||||
|
||||
// 금액 정보
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
|
||||
// 날짜 정보
|
||||
invoice_date: string;
|
||||
issue_date: string | null;
|
||||
|
||||
// 기타
|
||||
remarks: string;
|
||||
order_id: string | null;
|
||||
customer_id: string | null;
|
||||
|
||||
// 첨부파일 (JSON 배열로 저장)
|
||||
attachments: TaxInvoiceAttachment[] | null;
|
||||
|
||||
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
|
||||
cost_type: CostType | null;
|
||||
|
||||
created_date: string;
|
||||
updated_date: string;
|
||||
writer: string;
|
||||
}
|
||||
|
||||
// 첨부파일 타입
|
||||
export interface TaxInvoiceAttachment {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
uploaded_at: string;
|
||||
uploaded_by: string;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceItem {
|
||||
id: string;
|
||||
tax_invoice_id: string;
|
||||
company_code: string;
|
||||
item_seq: number;
|
||||
item_date: string;
|
||||
item_name: string;
|
||||
item_spec: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export interface CreateTaxInvoiceDto {
|
||||
invoice_type: "sales" | "purchase";
|
||||
supplier_business_no?: string;
|
||||
supplier_name?: string;
|
||||
supplier_ceo_name?: string;
|
||||
supplier_address?: string;
|
||||
supplier_business_type?: string;
|
||||
supplier_business_item?: string;
|
||||
buyer_business_no?: string;
|
||||
buyer_name?: string;
|
||||
buyer_ceo_name?: string;
|
||||
buyer_address?: string;
|
||||
buyer_email?: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
remarks?: string;
|
||||
order_id?: string;
|
||||
customer_id?: string;
|
||||
items?: CreateTaxInvoiceItemDto[];
|
||||
attachments?: TaxInvoiceAttachment[]; // 첨부파일
|
||||
cost_type?: CostType; // 비용 유형
|
||||
}
|
||||
|
||||
export interface CreateTaxInvoiceItemDto {
|
||||
item_date?: string;
|
||||
item_name: string;
|
||||
item_spec?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
invoice_type?: "sales" | "purchase";
|
||||
invoice_status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
search?: string;
|
||||
buyer_name?: string;
|
||||
cost_type?: CostType; // 비용 유형 필터
|
||||
}
|
||||
|
||||
export class TaxInvoiceService {
|
||||
/**
|
||||
* 세금계산서 번호 채번
|
||||
* 형식: YYYYMM-NNNNN (예: 202512-00001)
|
||||
*/
|
||||
static async generateInvoiceNumber(companyCode: string): Promise<string> {
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const prefix = `${yearMonth}-`;
|
||||
|
||||
// 해당 월의 마지막 번호 조회
|
||||
const result = await query<{ max_num: string }>(
|
||||
`SELECT invoice_number as max_num
|
||||
FROM tax_invoice
|
||||
WHERE company_code = $1
|
||||
AND invoice_number LIKE $2
|
||||
ORDER BY invoice_number DESC
|
||||
LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let nextNum = 1;
|
||||
if (result.length > 0 && result[0].max_num) {
|
||||
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
|
||||
nextNum = lastNum + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${String(nextNum).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
*/
|
||||
static async getList(
|
||||
companyCode: string,
|
||||
params: TaxInvoiceListParams
|
||||
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
invoice_type,
|
||||
invoice_status,
|
||||
start_date,
|
||||
end_date,
|
||||
search,
|
||||
buyer_name,
|
||||
cost_type,
|
||||
} = params;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (invoice_type) {
|
||||
conditions.push(`invoice_type = $${paramIndex}`);
|
||||
values.push(invoice_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (invoice_status) {
|
||||
conditions.push(`invoice_status = $${paramIndex}`);
|
||||
values.push(invoice_status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
conditions.push(`invoice_date >= $${paramIndex}`);
|
||||
values.push(start_date);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
conditions.push(`invoice_date <= $${paramIndex}`);
|
||||
values.push(end_date);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
|
||||
);
|
||||
values.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (buyer_name) {
|
||||
conditions.push(`buyer_name ILIKE $${paramIndex}`);
|
||||
values.push(`%${buyer_name}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (cost_type) {
|
||||
conditions.push(`cost_type = $${paramIndex}`);
|
||||
values.push(cost_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 전체 개수 조회
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
|
||||
values
|
||||
);
|
||||
const total = parseInt(countResult[0]?.count || "0", 10);
|
||||
|
||||
// 데이터 조회
|
||||
values.push(pageSize, offset);
|
||||
const data = await query<TaxInvoice>(
|
||||
`SELECT * FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
ORDER BY created_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
values
|
||||
);
|
||||
|
||||
return { data, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회 (품목 포함)
|
||||
*/
|
||||
static async getById(
|
||||
id: string,
|
||||
companyCode: string
|
||||
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
|
||||
const invoiceResult = await query<TaxInvoice>(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (invoiceResult.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = await query<TaxInvoiceItem>(
|
||||
`SELECT * FROM tax_invoice_item
|
||||
WHERE tax_invoice_id = $1 AND company_code = $2
|
||||
ORDER BY item_seq`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return { invoice: invoiceResult[0], items };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
*/
|
||||
static async create(
|
||||
data: CreateTaxInvoiceDto,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TaxInvoice> {
|
||||
return await transaction(async (client) => {
|
||||
// 세금계산서 번호 채번
|
||||
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
|
||||
|
||||
// 세금계산서 생성
|
||||
const invoiceResult = await client.query(
|
||||
`INSERT INTO tax_invoice (
|
||||
company_code, invoice_number, invoice_type, invoice_status,
|
||||
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
|
||||
supplier_business_type, supplier_business_item,
|
||||
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
|
||||
supply_amount, tax_amount, total_amount, invoice_date,
|
||||
remarks, order_id, customer_id, attachments, cost_type, writer
|
||||
) VALUES (
|
||||
$1, $2, $3, 'draft',
|
||||
$4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18,
|
||||
$19, $20, $21, $22, $23, $24
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
invoiceNumber,
|
||||
data.invoice_type,
|
||||
data.supplier_business_no || null,
|
||||
data.supplier_name || null,
|
||||
data.supplier_ceo_name || null,
|
||||
data.supplier_address || null,
|
||||
data.supplier_business_type || null,
|
||||
data.supplier_business_item || null,
|
||||
data.buyer_business_no || null,
|
||||
data.buyer_name || null,
|
||||
data.buyer_ceo_name || null,
|
||||
data.buyer_address || null,
|
||||
data.buyer_email || null,
|
||||
data.supply_amount,
|
||||
data.tax_amount,
|
||||
data.total_amount,
|
||||
data.invoice_date,
|
||||
data.remarks || null,
|
||||
data.order_id || null,
|
||||
data.customer_id || null,
|
||||
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||
data.cost_type || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
|
||||
// 품목 생성
|
||||
if (data.items && data.items.length > 0) {
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
await client.query(
|
||||
`INSERT INTO tax_invoice_item (
|
||||
tax_invoice_id, company_code, item_seq,
|
||||
item_date, item_name, item_spec, quantity, unit_price,
|
||||
supply_amount, tax_amount, remarks
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
invoice.id,
|
||||
companyCode,
|
||||
i + 1,
|
||||
item.item_date || null,
|
||||
item.item_name,
|
||||
item.item_spec || null,
|
||||
item.quantity,
|
||||
item.unit_price,
|
||||
item.supply_amount,
|
||||
item.tax_amount,
|
||||
item.remarks || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("세금계산서 생성 완료", {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
return invoice;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
*/
|
||||
static async update(
|
||||
id: string,
|
||||
data: Partial<CreateTaxInvoiceDto>,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TaxInvoice | null> {
|
||||
return await transaction(async (client) => {
|
||||
// 기존 세금계산서 확인
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 발행된 세금계산서는 수정 불가
|
||||
if (existing.rows[0].invoice_status !== "draft") {
|
||||
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 세금계산서 수정
|
||||
const updateResult = await client.query(
|
||||
`UPDATE tax_invoice SET
|
||||
supplier_business_no = COALESCE($3, supplier_business_no),
|
||||
supplier_name = COALESCE($4, supplier_name),
|
||||
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
|
||||
supplier_address = COALESCE($6, supplier_address),
|
||||
supplier_business_type = COALESCE($7, supplier_business_type),
|
||||
supplier_business_item = COALESCE($8, supplier_business_item),
|
||||
buyer_business_no = COALESCE($9, buyer_business_no),
|
||||
buyer_name = COALESCE($10, buyer_name),
|
||||
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
|
||||
buyer_address = COALESCE($12, buyer_address),
|
||||
buyer_email = COALESCE($13, buyer_email),
|
||||
supply_amount = COALESCE($14, supply_amount),
|
||||
tax_amount = COALESCE($15, tax_amount),
|
||||
total_amount = COALESCE($16, total_amount),
|
||||
invoice_date = COALESCE($17, invoice_date),
|
||||
remarks = COALESCE($18, remarks),
|
||||
attachments = $19,
|
||||
cost_type = COALESCE($20, cost_type),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
companyCode,
|
||||
data.supplier_business_no,
|
||||
data.supplier_name,
|
||||
data.supplier_ceo_name,
|
||||
data.supplier_address,
|
||||
data.supplier_business_type,
|
||||
data.supplier_business_item,
|
||||
data.buyer_business_no,
|
||||
data.buyer_name,
|
||||
data.buyer_ceo_name,
|
||||
data.buyer_address,
|
||||
data.buyer_email,
|
||||
data.supply_amount,
|
||||
data.tax_amount,
|
||||
data.total_amount,
|
||||
data.invoice_date,
|
||||
data.remarks,
|
||||
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||
data.cost_type,
|
||||
]
|
||||
);
|
||||
|
||||
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||
if (data.items) {
|
||||
await client.query(
|
||||
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
await client.query(
|
||||
`INSERT INTO tax_invoice_item (
|
||||
tax_invoice_id, company_code, item_seq,
|
||||
item_date, item_name, item_spec, quantity, unit_price,
|
||||
supply_amount, tax_amount, remarks
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
id,
|
||||
companyCode,
|
||||
i + 1,
|
||||
item.item_date || null,
|
||||
item.item_name,
|
||||
item.item_spec || null,
|
||||
item.quantity,
|
||||
item.unit_price,
|
||||
item.supply_amount,
|
||||
item.tax_amount,
|
||||
item.remarks || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return updateResult.rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
|
||||
return await transaction(async (client) => {
|
||||
// 기존 세금계산서 확인
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 발행된 세금계산서는 삭제 불가
|
||||
if (existing.rows[0].invoice_status !== "draft") {
|
||||
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 품목 삭제
|
||||
await client.query(
|
||||
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
// 세금계산서 삭제
|
||||
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행 (상태 변경)
|
||||
*/
|
||||
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
|
||||
const result = await query<TaxInvoice>(
|
||||
`UPDATE tax_invoice SET
|
||||
invoice_status = 'issued',
|
||||
issue_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
|
||||
RETURNING *`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
static async cancel(
|
||||
id: string,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
reason?: string
|
||||
): Promise<TaxInvoice | null> {
|
||||
const result = await query<TaxInvoice>(
|
||||
`UPDATE tax_invoice SET
|
||||
invoice_status = 'cancelled',
|
||||
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
|
||||
RETURNING *`,
|
||||
[id, companyCode, reason || null]
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
static async getMonthlyStats(
|
||||
companyCode: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<{
|
||||
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
}> {
|
||||
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
|
||||
|
||||
const result = await query<{
|
||||
invoice_type: string;
|
||||
count: string;
|
||||
supply_amount: string;
|
||||
tax_amount: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
invoice_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE company_code = $1
|
||||
AND invoice_date >= $2
|
||||
AND invoice_date <= $3
|
||||
AND invoice_status != 'cancelled'
|
||||
GROUP BY invoice_type`,
|
||||
[companyCode, startDate, endDate]
|
||||
);
|
||||
|
||||
const stats = {
|
||||
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||
};
|
||||
|
||||
for (const row of result) {
|
||||
const type = row.invoice_type as "sales" | "purchase";
|
||||
stats[type] = {
|
||||
count: parseInt(row.count, 10),
|
||||
supply_amount: parseFloat(row.supply_amount),
|
||||
tax_amount: parseFloat(row.tax_amount),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
*/
|
||||
static async getCostTypeStats(
|
||||
companyCode: string,
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<{
|
||||
by_cost_type: Array<{
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
by_month: Array<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
summary: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
purchase_amount: number;
|
||||
installation_amount: number;
|
||||
repair_amount: number;
|
||||
maintenance_amount: number;
|
||||
disposal_amount: number;
|
||||
other_amount: number;
|
||||
};
|
||||
}> {
|
||||
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
|
||||
const values: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 연도/월 필터
|
||||
if (year && month) {
|
||||
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
|
||||
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
|
||||
values.push(startDate, endDate);
|
||||
paramIndex += 2;
|
||||
} else if (year) {
|
||||
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
|
||||
values.push(year);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 비용 유형별 집계
|
||||
const byCostType = await query<{
|
||||
cost_type: CostType | null;
|
||||
count: string;
|
||||
supply_amount: string;
|
||||
tax_amount: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
cost_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
GROUP BY cost_type
|
||||
ORDER BY total_amount DESC`,
|
||||
values
|
||||
);
|
||||
|
||||
// 월별 비용 유형 집계
|
||||
const byMonth = await query<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: string;
|
||||
total_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
|
||||
cost_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}
|
||||
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
|
||||
ORDER BY year_month DESC, cost_type`,
|
||||
values
|
||||
);
|
||||
|
||||
// 전체 요약
|
||||
const summaryResult = await query<{
|
||||
total_count: string;
|
||||
total_amount: string;
|
||||
purchase_amount: string;
|
||||
installation_amount: string;
|
||||
repair_amount: string;
|
||||
maintenance_amount: string;
|
||||
disposal_amount: string;
|
||||
other_amount: string;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
|
||||
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
|
||||
FROM tax_invoice
|
||||
WHERE ${whereClause}`,
|
||||
values
|
||||
);
|
||||
|
||||
const summary = summaryResult[0] || {
|
||||
total_count: "0",
|
||||
total_amount: "0",
|
||||
purchase_amount: "0",
|
||||
installation_amount: "0",
|
||||
repair_amount: "0",
|
||||
maintenance_amount: "0",
|
||||
disposal_amount: "0",
|
||||
other_amount: "0",
|
||||
};
|
||||
|
||||
return {
|
||||
by_cost_type: byCostType.map((row) => ({
|
||||
cost_type: row.cost_type,
|
||||
count: parseInt(row.count, 10),
|
||||
supply_amount: parseFloat(row.supply_amount),
|
||||
tax_amount: parseFloat(row.tax_amount),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
})),
|
||||
by_month: byMonth.map((row) => ({
|
||||
year_month: row.year_month,
|
||||
cost_type: row.cost_type,
|
||||
count: parseInt(row.count, 10),
|
||||
total_amount: parseFloat(row.total_amount),
|
||||
})),
|
||||
summary: {
|
||||
total_count: parseInt(summary.total_count, 10),
|
||||
total_amount: parseFloat(summary.total_amount),
|
||||
purchase_amount: parseFloat(summary.purchase_amount),
|
||||
installation_amount: parseFloat(summary.installation_amount),
|
||||
repair_amount: parseFloat(summary.repair_amount),
|
||||
maintenance_amount: parseFloat(summary.maintenance_amount),
|
||||
disposal_amount: parseFloat(summary.disposal_amount),
|
||||
other_amount: parseFloat(summary.other_amount),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
# 노드 플로우 기능 개선 사항
|
||||
|
||||
> 작성일: 2024-12-08
|
||||
> 상태: 분석 완료, 개선 대기
|
||||
|
||||
## 현재 구현 상태
|
||||
|
||||
### 잘 구현된 기능
|
||||
|
||||
| 기능 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| 위상 정렬 실행 | 완료 | DAG 기반 레벨별 실행 |
|
||||
| 트랜잭션 관리 | 완료 | 전체 플로우 단일 트랜잭션, 실패 시 자동 롤백 |
|
||||
| 병렬 실행 | 완료 | 같은 레벨 노드 `Promise.allSettled`로 병렬 처리 |
|
||||
| CRUD 액션 | 완료 | INSERT, UPDATE, DELETE, UPSERT 지원 |
|
||||
| 외부 DB 연동 | 완료 | PostgreSQL, MySQL, MSSQL, Oracle 지원 |
|
||||
| REST API 연동 | 완료 | GET, POST, PUT, DELETE 지원 |
|
||||
| 조건 분기 | 완료 | 다양한 연산자 지원 |
|
||||
| 데이터 변환 | 부분 완료 | UPPERCASE, TRIM, EXPLODE 등 기본 변환 |
|
||||
| 집계 함수 | 완료 | SUM, COUNT, AVG, MIN, MAX, FIRST, LAST |
|
||||
|
||||
### 관련 파일
|
||||
|
||||
- **백엔드 실행 엔진**: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||
- **백엔드 라우트**: `backend-node/src/routes/dataflow/node-flows.ts`
|
||||
- **프론트엔드 API**: `frontend/lib/api/nodeFlows.ts`
|
||||
- **프론트엔드 에디터**: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||
- **타입 정의**: `backend-node/src/types/flow.ts`
|
||||
|
||||
---
|
||||
|
||||
## 개선 필요 사항
|
||||
|
||||
### 1. [우선순위 높음] 실행 이력 로깅
|
||||
|
||||
**현재 상태**: 플로우 실행 이력이 저장되지 않음
|
||||
|
||||
**문제점**:
|
||||
- 언제, 누가, 어떤 플로우를 실행했는지 추적 불가
|
||||
- 실패 원인 분석 어려움
|
||||
- 감사(Audit) 요구사항 충족 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- db/migrations/XXX_add_node_flow_execution_log.sql
|
||||
CREATE TABLE node_flow_execution_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
execution_status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'partial'
|
||||
execution_time_ms INTEGER,
|
||||
total_nodes INTEGER,
|
||||
success_nodes INTEGER,
|
||||
failed_nodes INTEGER,
|
||||
skipped_nodes INTEGER,
|
||||
executed_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
context_data JSONB,
|
||||
result_summary JSONB,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_flow_execution_log_flow_id ON node_flow_execution_log(flow_id);
|
||||
CREATE INDEX idx_flow_execution_log_created_at ON node_flow_execution_log(created_at DESC);
|
||||
CREATE INDEX idx_flow_execution_log_company_code ON node_flow_execution_log(company_code);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 마이그레이션 파일 생성
|
||||
- [ ] `nodeFlowExecutionService.ts`에 로그 저장 로직 추가
|
||||
- [ ] 실행 이력 조회 API 추가 (`GET /api/dataflow/node-flows/:flowId/executions`)
|
||||
- [ ] 프론트엔드 실행 이력 UI 추가
|
||||
|
||||
---
|
||||
|
||||
### 2. [우선순위 높음] 드라이런(Dry Run) 모드
|
||||
|
||||
**현재 상태**: 실제 데이터를 변경하지 않고 테스트할 방법 없음
|
||||
|
||||
**문제점**:
|
||||
- 프로덕션 데이터에 직접 영향
|
||||
- 플로우 디버깅 어려움
|
||||
- 신규 플로우 검증 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts
|
||||
static async executeFlow(
|
||||
flowId: number,
|
||||
contextData: Record<string, any>,
|
||||
options: { dryRun?: boolean } = {}
|
||||
): Promise<ExecutionResult> {
|
||||
if (options.dryRun) {
|
||||
// 트랜잭션 시작 후 항상 롤백
|
||||
return transaction(async (client) => {
|
||||
const result = await this.executeFlowInternal(flowId, contextData, client);
|
||||
// 롤백을 위해 의도적으로 에러 발생
|
||||
throw new DryRunComplete(result);
|
||||
}).catch((e) => {
|
||||
if (e instanceof DryRunComplete) {
|
||||
return { ...e.result, dryRun: true };
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
// 기존 로직...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// node-flows.ts 라우트 수정
|
||||
router.post("/:flowId/execute", async (req, res) => {
|
||||
const dryRun = req.query.dryRun === 'true';
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
enrichedContextData,
|
||||
{ dryRun }
|
||||
);
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `DryRunComplete` 예외 클래스 생성
|
||||
- [ ] `executeFlow` 메서드에 `dryRun` 옵션 추가
|
||||
- [ ] 라우트에 쿼리 파라미터 처리 추가
|
||||
- [ ] 프론트엔드 "테스트 실행" 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
### 3. [우선순위 높음] 재시도 메커니즘
|
||||
|
||||
**현재 상태**: 외부 API/DB 호출 실패 시 재시도 없음
|
||||
|
||||
**문제점**:
|
||||
- 일시적 네트워크 오류로 전체 플로우 실패
|
||||
- 외부 서비스 불안정 시 신뢰성 저하
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// utils/retry.ts
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
delay?: number;
|
||||
backoffMultiplier?: number;
|
||||
retryOn?: (error: any) => boolean;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
delay = 1000,
|
||||
backoffMultiplier = 2,
|
||||
retryOn = () => true
|
||||
} = options;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1 || !retryOn(error)) {
|
||||
throw error;
|
||||
}
|
||||
const waitTime = delay * Math.pow(backoffMultiplier, attempt);
|
||||
logger.warn(`재시도 ${attempt + 1}/${maxRetries}, ${waitTime}ms 후...`);
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
}
|
||||
}
|
||||
throw new Error('재시도 횟수 초과');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts에서 사용
|
||||
const response = await withRetry(
|
||||
() => axios({ method, url, headers, data, timeout }),
|
||||
{
|
||||
maxRetries: 3,
|
||||
delay: 1000,
|
||||
retryOn: (err) => err.code === 'ECONNRESET' || err.response?.status >= 500
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `withRetry` 유틸리티 함수 생성
|
||||
- [ ] REST API 호출 부분에 재시도 로직 적용
|
||||
- [ ] 외부 DB 연결 부분에 재시도 로직 적용
|
||||
- [ ] 노드별 재시도 설정 UI 추가 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
### 4. [우선순위 높음] 미완성 데이터 변환 함수
|
||||
|
||||
**현재 상태**: FORMAT, CALCULATE, JSON_EXTRACT, CUSTOM 변환이 미구현
|
||||
|
||||
**문제점**:
|
||||
- 날짜/숫자 포맷팅 불가
|
||||
- 계산식 처리 불가
|
||||
- JSON 데이터 파싱 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts - applyTransformation 메서드 수정
|
||||
|
||||
case "FORMAT":
|
||||
return rows.map((row) => {
|
||||
const value = row[sourceField];
|
||||
let formatted = value;
|
||||
|
||||
if (transform.formatType === 'date') {
|
||||
// dayjs 사용
|
||||
formatted = dayjs(value).format(transform.formatPattern || 'YYYY-MM-DD');
|
||||
} else if (transform.formatType === 'number') {
|
||||
// 숫자 포맷팅
|
||||
const num = parseFloat(value);
|
||||
if (transform.formatPattern === 'currency') {
|
||||
formatted = num.toLocaleString('ko-KR', { style: 'currency', currency: 'KRW' });
|
||||
} else if (transform.formatPattern === 'percent') {
|
||||
formatted = (num * 100).toFixed(transform.decimals || 0) + '%';
|
||||
} else {
|
||||
formatted = num.toLocaleString('ko-KR', { maximumFractionDigits: transform.decimals || 2 });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...row, [actualTargetField]: formatted };
|
||||
});
|
||||
|
||||
case "CALCULATE":
|
||||
return rows.map((row) => {
|
||||
// 간단한 수식 평가 (보안 주의!)
|
||||
const expression = transform.expression; // 예: "price * quantity"
|
||||
const result = evaluateExpression(expression, row);
|
||||
return { ...row, [actualTargetField]: result };
|
||||
});
|
||||
|
||||
case "JSON_EXTRACT":
|
||||
return rows.map((row) => {
|
||||
const jsonValue = typeof row[sourceField] === 'string'
|
||||
? JSON.parse(row[sourceField])
|
||||
: row[sourceField];
|
||||
const extracted = jsonPath.query(jsonValue, transform.jsonPath); // JSONPath 라이브러리 사용
|
||||
return { ...row, [actualTargetField]: extracted[0] || null };
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `dayjs` 라이브러리 추가 (날짜 포맷팅)
|
||||
- [ ] `jsonpath` 라이브러리 추가 (JSON 추출)
|
||||
- [ ] 안전한 수식 평가 함수 구현 (eval 대신)
|
||||
- [ ] 각 변환 타입별 UI 설정 패널 추가
|
||||
|
||||
---
|
||||
|
||||
### 5. [우선순위 중간] 플로우 버전 관리
|
||||
|
||||
**현재 상태**: 플로우 수정 시 이전 버전 덮어씀
|
||||
|
||||
**문제점**:
|
||||
- 실수로 수정한 플로우 복구 불가
|
||||
- 변경 이력 추적 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- db/migrations/XXX_add_node_flow_versions.sql
|
||||
CREATE TABLE node_flow_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
flow_data JSONB NOT NULL,
|
||||
change_description TEXT,
|
||||
created_by VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(flow_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_flow_versions_flow_id ON node_flow_versions(flow_id);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 플로우 수정 시 버전 저장
|
||||
async function updateNodeFlow(flowId, flowData, changeDescription, userId) {
|
||||
// 현재 버전 조회
|
||||
const currentVersion = await queryOne(
|
||||
'SELECT COALESCE(MAX(version), 0) as max_version FROM node_flow_versions WHERE flow_id = $1',
|
||||
[flowId]
|
||||
);
|
||||
|
||||
// 새 버전 저장
|
||||
await query(
|
||||
'INSERT INTO node_flow_versions (flow_id, version, flow_data, change_description, created_by) VALUES ($1, $2, $3, $4, $5)',
|
||||
[flowId, currentVersion.max_version + 1, flowData, changeDescription, userId]
|
||||
);
|
||||
|
||||
// 기존 업데이트 로직...
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 버전 테이블 마이그레이션 생성
|
||||
- [ ] 플로우 수정 시 버전 자동 저장
|
||||
- [ ] 버전 목록 조회 API (`GET /api/dataflow/node-flows/:flowId/versions`)
|
||||
- [ ] 특정 버전으로 롤백 API (`POST /api/dataflow/node-flows/:flowId/rollback/:version`)
|
||||
- [ ] 프론트엔드 버전 히스토리 UI
|
||||
|
||||
---
|
||||
|
||||
### 6. [우선순위 중간] 복합 조건 지원
|
||||
|
||||
**현재 상태**: 조건 노드에서 단일 조건만 지원
|
||||
|
||||
**문제점**:
|
||||
- 복잡한 비즈니스 로직 표현 불가
|
||||
- 여러 조건을 AND/OR로 조합 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// 복합 조건 타입 정의
|
||||
interface ConditionGroup {
|
||||
type: 'AND' | 'OR';
|
||||
conditions: (Condition | ConditionGroup)[];
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// 조건 평가 함수 수정
|
||||
function evaluateConditionGroup(group: ConditionGroup, data: any): boolean {
|
||||
const results = group.conditions.map(condition => {
|
||||
if ('type' in condition) {
|
||||
// 중첩된 그룹
|
||||
return evaluateConditionGroup(condition, data);
|
||||
} else {
|
||||
// 단일 조건
|
||||
return evaluateCondition(data[condition.field], condition.operator, condition.value);
|
||||
}
|
||||
});
|
||||
|
||||
return group.type === 'AND'
|
||||
? results.every(r => r)
|
||||
: results.some(r => r);
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 복합 조건 타입 정의
|
||||
- [ ] `evaluateConditionGroup` 함수 구현
|
||||
- [ ] 조건 노드 속성 패널 UI 수정 (AND/OR 그룹 빌더)
|
||||
|
||||
---
|
||||
|
||||
### 7. [우선순위 중간] 비동기 실행
|
||||
|
||||
**현재 상태**: 동기 실행만 가능 (HTTP 요청 타임아웃 제한)
|
||||
|
||||
**문제점**:
|
||||
- 대용량 데이터 처리 시 타임아웃
|
||||
- 장시간 실행 플로우 처리 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- 실행 큐 테이블
|
||||
CREATE TABLE node_flow_execution_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id),
|
||||
execution_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed
|
||||
context_data JSONB,
|
||||
callback_url TEXT,
|
||||
result JSONB,
|
||||
error_message TEXT,
|
||||
queued_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
queued_at TIMESTAMP DEFAULT NOW(),
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 비동기 실행 API
|
||||
router.post("/:flowId/execute-async", async (req, res) => {
|
||||
const { callbackUrl, contextData } = req.body;
|
||||
|
||||
// 큐에 추가
|
||||
const execution = await queryOne(
|
||||
`INSERT INTO node_flow_execution_queue (flow_id, context_data, callback_url, queued_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING execution_id`,
|
||||
[flowId, contextData, callbackUrl, req.user?.userId, req.user?.companyCode]
|
||||
);
|
||||
|
||||
// 백그라운드 워커가 처리
|
||||
return res.json({
|
||||
success: true,
|
||||
executionId: execution.execution_id,
|
||||
status: 'queued'
|
||||
});
|
||||
});
|
||||
|
||||
// 상태 조회 API
|
||||
router.get("/executions/:executionId", async (req, res) => {
|
||||
const execution = await queryOne(
|
||||
'SELECT * FROM node_flow_execution_queue WHERE execution_id = $1',
|
||||
[req.params.executionId]
|
||||
);
|
||||
return res.json({ success: true, data: execution });
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 실행 큐 테이블 마이그레이션
|
||||
- [ ] 비동기 실행 API 추가
|
||||
- [ ] 백그라운드 워커 프로세스 구현 (별도 프로세스 또는 Bull 큐)
|
||||
- [ ] 웹훅 콜백 기능 구현
|
||||
- [ ] 프론트엔드 비동기 실행 상태 폴링 UI
|
||||
|
||||
---
|
||||
|
||||
### 8. [우선순위 낮음] 플로우 스케줄링
|
||||
|
||||
**현재 상태**: 수동 실행만 가능
|
||||
|
||||
**문제점**:
|
||||
- 정기적인 배치 작업 자동화 불가
|
||||
- 특정 시간 예약 실행 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- 스케줄 테이블
|
||||
CREATE TABLE node_flow_schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
schedule_name VARCHAR(100),
|
||||
cron_expression VARCHAR(50) NOT NULL, -- '0 9 * * 1-5' (평일 9시)
|
||||
context_data JSONB,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_run_at TIMESTAMP,
|
||||
next_run_at TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 스케줄 테이블 마이그레이션
|
||||
- [ ] 스케줄 CRUD API
|
||||
- [ ] node-cron 또는 Bull 스케줄러 통합
|
||||
- [ ] 스케줄 관리 UI
|
||||
|
||||
---
|
||||
|
||||
### 9. [우선순위 낮음] 플러그인 아키텍처
|
||||
|
||||
**현재 상태**: 새 노드 타입 추가 시 `nodeFlowExecutionService.ts` 직접 수정 필요
|
||||
|
||||
**문제점**:
|
||||
- 코드 복잡도 증가
|
||||
- 확장성 제한
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// interfaces/NodeHandler.ts
|
||||
export interface NodeHandler {
|
||||
type: string;
|
||||
execute(node: FlowNode, inputData: any, context: ExecutionContext, client?: any): Promise<any>;
|
||||
validate?(node: FlowNode): { valid: boolean; errors: string[] };
|
||||
}
|
||||
|
||||
// handlers/InsertActionHandler.ts
|
||||
export class InsertActionHandler implements NodeHandler {
|
||||
type = 'insertAction';
|
||||
|
||||
async execute(node, inputData, context, client) {
|
||||
// 기존 executeInsertAction 로직
|
||||
}
|
||||
}
|
||||
|
||||
// NodeHandlerRegistry.ts
|
||||
class NodeHandlerRegistry {
|
||||
private handlers = new Map<string, NodeHandler>();
|
||||
|
||||
register(handler: NodeHandler) {
|
||||
this.handlers.set(handler.type, handler);
|
||||
}
|
||||
|
||||
get(type: string): NodeHandler | undefined {
|
||||
return this.handlers.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용
|
||||
const registry = new NodeHandlerRegistry();
|
||||
registry.register(new InsertActionHandler());
|
||||
registry.register(new UpdateActionHandler());
|
||||
// ...
|
||||
|
||||
// executeNodeByType에서
|
||||
const handler = registry.get(node.type);
|
||||
if (handler) {
|
||||
return handler.execute(node, inputData, context, client);
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `NodeHandler` 인터페이스 정의
|
||||
- [ ] 기존 노드 타입별 핸들러 클래스 분리
|
||||
- [ ] `NodeHandlerRegistry` 구현
|
||||
- [ ] 커스텀 노드 핸들러 등록 메커니즘
|
||||
|
||||
---
|
||||
|
||||
### 10. [우선순위 낮음] 프론트엔드 연동 강화
|
||||
|
||||
**현재 상태**: 기본 에디터 구현됨
|
||||
|
||||
**개선 필요 항목**:
|
||||
- [ ] 실행 결과 시각화 (노드별 성공/실패 표시)
|
||||
- [ ] 실시간 실행 진행률 표시
|
||||
- [ ] 드라이런 모드 UI
|
||||
- [ ] 실행 이력 조회 UI
|
||||
- [ ] 버전 히스토리 UI
|
||||
- [ ] 노드 검증 결과 표시
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 컴포넌트 CRUD 로직 이전 계획
|
||||
|
||||
현재 프론트엔드 컴포넌트에서 직접 CRUD를 수행하는 코드들을 노드 플로우로 이전해야 합니다.
|
||||
|
||||
### 이전 대상 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 위치 | 현재 로직 | 이전 우선순위 |
|
||||
|----------|----------|----------|--------------|
|
||||
| SplitPanelLayoutComponent | `frontend/lib/registry/components/split-panel-layout/` | createRecord, updateRecord, deleteRecord | 높음 |
|
||||
| RepeatScreenModalComponent | `frontend/lib/registry/components/repeat-screen-modal/` | 다중 테이블 INSERT/UPDATE/DELETE | 높음 |
|
||||
| UniversalFormModalComponent | `frontend/lib/registry/components/universal-form-modal/` | 다중 행 저장 | 높음 |
|
||||
| SelectedItemsDetailInputComponent | `frontend/lib/registry/components/selected-items-detail-input/` | upsertGroupedRecords | 높음 |
|
||||
| ButtonPrimaryComponent | `frontend/lib/registry/components/button-primary/` | 상태 변경 POST | 중간 |
|
||||
| SimpleRepeaterTableComponent | `frontend/lib/registry/components/simple-repeater-table/` | 데이터 저장 POST | 중간 |
|
||||
|
||||
### 이전 방식
|
||||
|
||||
1. **플로우 생성**: 각 컴포넌트의 저장 로직을 노드 플로우로 구현
|
||||
2. **프론트엔드 수정**: 직접 API 호출 대신 `executeNodeFlow(flowId, contextData)` 호출
|
||||
3. **화면 설정에 플로우 연결**: 버튼 액션에 실행할 플로우 ID 설정
|
||||
|
||||
```typescript
|
||||
// 현재 (프론트엔드에서 직접 호출)
|
||||
const result = await dataApi.createRecord(tableName, data);
|
||||
|
||||
// 개선 후 (플로우 실행)
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
formData: data,
|
||||
tableName: tableName,
|
||||
action: 'create'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- 노드 플로우 실행 엔진: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||
- 플로우 타입 정의: `backend-node/src/types/flow.ts`
|
||||
- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
||||
|
||||
|
||||
|
|
@ -216,7 +216,8 @@ function ScreenViewPage() {
|
|||
initAutoFill();
|
||||
}, [layout, user]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||
useEffect(() => {
|
||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||
if (isMobile) {
|
||||
|
|
@ -262,13 +263,12 @@ function ScreenViewPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 초기 측정
|
||||
// 초기 측정 (한 번만 실행)
|
||||
const timer = setTimeout(updateScale, 100);
|
||||
|
||||
window.addEventListener("resize", updateScale);
|
||||
// resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", updateScale);
|
||||
};
|
||||
}, [layout, isMobile]);
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ function ScreenViewPage() {
|
|||
<TableOptionsProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-background flex h-full w-full items-center justify-center overflow-auto"
|
||||
className="bg-background h-full w-full overflow-auto p-3"
|
||||
>
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
|
|
@ -334,7 +334,7 @@ function ScreenViewPage() {
|
|||
maxHeight: `${screenHeight}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
transformOrigin: "top left",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -197,14 +197,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
컬럼 추가 - {tableName}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 검증 오류 표시 */}
|
||||
|
|
@ -346,7 +346,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
</Alert>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -365,8 +365,8 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
"컬럼 추가"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ export default function AdvancedBatchModal({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogHeader,
|
||||
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -169,13 +169,13 @@ export default function BatchJobModal({
|
|||
// 상태 제거 - 필요없음
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -344,7 +344,7 @@ export default function BatchJobModal({
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -360,9 +360,9 @@ export default function BatchJobModal({
|
|||
>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -164,11 +164,11 @@ export function CodeCategoryFormModal({
|
|||
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 카테고리 코드 */}
|
||||
|
|
@ -383,7 +383,7 @@ export function CodeCategoryFormModal({
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -153,11 +153,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 코드값 */}
|
||||
|
|
@ -328,7 +328,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogHeader,
|
||||
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -164,13 +164,13 @@ export default function CollectionConfigModal({
|
|||
];
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -331,16 +331,16 @@ export default function CollectionConfigModal({
|
|||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
||||
|
||||
|
|
@ -111,8 +111,8 @@ export function CompanyFormModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
||||
<ResizableDialogContent
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultWidth={500}
|
||||
|
|
@ -124,9 +124,9 @@ export function CompanyFormModal({
|
|||
modalId="company-form"
|
||||
userId={modalState.companyCode}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 회사명 입력 (필수) */}
|
||||
|
|
@ -255,7 +255,7 @@ export function CompanyFormModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -273,8 +273,8 @@ export function CompanyFormModal({
|
|||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||
{isEditMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -321,20 +321,20 @@ export function CreateTableModal({
|
|||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDuplicateMode
|
||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
||||
}
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 기본 정보 */}
|
||||
|
|
@ -452,7 +452,7 @@ export function CreateTableModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2">
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -482,8 +482,8 @@ export function CreateTableModal({
|
|||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -148,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-7xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
DDL 실행 로그 및 통계
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
|
|
@ -407,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -266,13 +266,13 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -580,8 +580,8 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
>
|
||||
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
ExternalDbConnectionAPI,
|
||||
|
|
@ -311,13 +311,13 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -623,8 +623,8 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
>
|
||||
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -66,11 +66,11 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="companyCode">회사</Label>
|
||||
|
|
@ -131,7 +131,7 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
|||
<Button type="submit">{keyData ? "수정" : "추가"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -68,11 +68,11 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
|
@ -141,8 +141,8 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
|||
<Button type="submit">{languageData ? "수정" : "추가"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -225,14 +225,14 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 단계 표시기 */}
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
|
|
@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2">
|
||||
<DialogFooter className="gap-2">
|
||||
{step !== "basic" && !generationResult && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
이전
|
||||
|
|
@ -527,8 +527,8 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<Button variant="outline" onClick={handleClose}>
|
||||
{generationResult?.success ? "완료" : "취소"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -684,15 +684,15 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[600px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
||||
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -1067,7 +1067,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
ExternalRestApiConnectionAPI,
|
||||
|
|
@ -275,11 +275,11 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
취소
|
||||
|
|
@ -597,8 +597,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "저장 중..." : connection ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
|
@ -71,11 +71,11 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
if (!role) return null;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">권한 그룹 삭제</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">권한 그룹 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 경고 메시지 */}
|
||||
|
|
@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -150,8 +150,8 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
>
|
||||
{isLoading ? "삭제중..." : "삭제"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -184,11 +184,11 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 권한 그룹명 */}
|
||||
|
|
@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -375,8 +375,8 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
>
|
||||
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -179,14 +179,14 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto" aria-describedby="modal-description">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{connectionName} - SQL 쿼리 실행</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden" aria-describedby="modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{connectionName} - SQL 쿼리 실행</DialogTitle>
|
||||
<DialogDescription>
|
||||
데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 쿼리 입력 영역 */}
|
||||
<div className="space-y-4">
|
||||
|
|
@ -228,7 +228,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
<div className="bg-muted/50 space-y-4 rounded-md border p-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">사용 가능한 테이블</h3>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="max-h-[200px] overflow-hidden">
|
||||
<div className="space-y-2 pr-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="bg-card rounded-lg border p-3 shadow-sm">
|
||||
|
|
@ -263,7 +263,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
{loadingColumns ? (
|
||||
<div className="text-muted-foreground text-sm">컬럼 정보 로딩 중...</div>
|
||||
) : selectedTableColumns.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="max-h-[200px] overflow-hidden">
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -332,7 +332,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
|
||||
{/* 결과 그리드 */}
|
||||
<div className="bg-card rounded-md border">
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-hidden">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
|
|
@ -378,7 +378,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -126,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{tableName} - 변경 이력
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
|
|
@ -261,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTemplates } from "@/hooks/admin/useTemplates";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -32,11 +32,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className={getTypeColor()}>{title}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground text-sm">{message}</p>
|
||||
</div>
|
||||
|
|
@ -45,8 +45,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -441,11 +441,11 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -684,8 +684,8 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -152,17 +152,17 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
||||
<ResizableDialogHeader className="flex-shrink-0">
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
사용자 관리 이력
|
||||
</ResizableDialogTitle>
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{userName} ({userId})의 변경이력을 조회합니다.
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 로딩 상태 */}
|
||||
|
|
@ -254,7 +254,7 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
|||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -127,11 +127,11 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
if (!userId) return null;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>비밀번호 초기화</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀번호 초기화</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4" onKeyDown={handleKeyDown}>
|
||||
{/* 대상 사용자 정보 */}
|
||||
|
|
@ -215,7 +215,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
{isLoading ? "처리중..." : "초기화"}
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</DialogContent>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
@ -225,6 +225,6 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
/>
|
||||
</ResizableDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import { DashboardProvider } from "@/contexts/DashboardContext";
|
|||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -639,23 +639,23 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
/>
|
||||
|
||||
{/* 저장 성공 모달 */}
|
||||
<ResizableDialog
|
||||
<Dialog
|
||||
open={successModalOpen}
|
||||
onOpenChange={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<CheckCircle2 className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<ResizableDialogTitle className="text-center">저장 완료</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-center">
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
대시보드가 성공적으로 저장되었습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
@ -666,8 +666,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 초기화 확인 모달 */}
|
||||
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -174,11 +174,11 @@ export function DashboardSaveModal({
|
|||
const flatMenus = flattenMenus(currentMenus);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 대시보드 이름 */}
|
||||
|
|
@ -312,7 +312,7 @@ export function DashboardSaveModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -329,8 +329,8 @@ export function DashboardSaveModal({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -116,14 +116,14 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>대시보드 저장 완료</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</ResizableDialogDescription>
|
||||
<DialogTitle>대시보드 저장 완료</DialogTitle>
|
||||
<DialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</DialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -200,13 +200,13 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>{assignToMenu ? "메뉴에 할당하고 완료" : "완료"}</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -233,7 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. 자동 새로고침 간격 */}
|
||||
{/* 6. 소수점 자릿수 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">소수점 자릿수</Label>
|
||||
<Select
|
||||
value={(config.decimals ?? 0).toString()}
|
||||
onValueChange={(value) => onConfigChange({ decimals: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="자릿수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">정수 (0자리)</SelectItem>
|
||||
<SelectItem value="1" className="text-xs">소수점 1자리</SelectItem>
|
||||
<SelectItem value="2" className="text-xs">소수점 2자리</SelectItem>
|
||||
<SelectItem value="3" className="text-xs">소수점 3자리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
표시할 소수점 자릿수 (평균, 비율 등에 유용)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 7. 자동 새로고침 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
|
@ -94,10 +94,10 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>자재 배치 설정</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>자재 배치 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 자재 정보 */}
|
||||
|
|
@ -233,7 +233,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isAdding}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -247,8 +247,8 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
"배치"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { materialApi } from "@/lib/api/yardLayoutApi";
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -64,14 +64,14 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>새로운 3D필드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>필드 이름을 입력하세요</ResizableDialogDescription>
|
||||
<DialogTitle>새로운 3D필드 생성</DialogTitle>
|
||||
<DialogDescription>필드 이름을 입력하세요</DialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -100,7 +100,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -114,8 +114,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
||||
|
|
@ -179,26 +179,15 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
className="max-w-[95vw] sm:max-w-[600px]"
|
||||
defaultWidth={600}
|
||||
defaultHeight={700}
|
||||
minWidth={400}
|
||||
minHeight={500}
|
||||
maxWidth={900}
|
||||
maxHeight={900}
|
||||
modalId="barcode-scan"
|
||||
userId={userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">바코드 스캔</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
카메라로 바코드를 스캔하세요.
|
||||
{targetField && ` (대상 필드: ${targetField})`}
|
||||
모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 카메라 권한 요청 대기 중 */}
|
||||
|
|
@ -337,7 +326,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
|
|
@ -376,9 +365,9 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
확인
|
||||
</Button>
|
||||
)}
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -385,27 +385,27 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}, [open]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
defaultWidth={1000}
|
||||
defaultHeight={700}
|
||||
minWidth={700}
|
||||
minHeight={500}
|
||||
maxWidth={1400}
|
||||
maxHeight={900}
|
||||
modalId={`excel-upload-${tableName}`}
|
||||
userId={userId || "guest"}
|
||||
style={{
|
||||
width: "1000px",
|
||||
height: "700px",
|
||||
minWidth: "700px",
|
||||
minHeight: "500px",
|
||||
maxWidth: "1400px",
|
||||
maxHeight: "900px",
|
||||
}}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
엑셀 데이터 업로드
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 스텝 인디케이터 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -863,7 +863,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
||||
|
|
@ -889,8 +889,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
{isUploading ? "업로드 중..." : "다음"}
|
||||
</Button>
|
||||
)}
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
|
@ -514,16 +514,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
|
|
@ -593,36 +595,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })} // undefined일 때는 prop 자체를 전달하지 않음
|
||||
defaultWidth={600}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1600}
|
||||
maxHeight={1200}
|
||||
modalId={persistedModalId}
|
||||
userId={userId || "guest"}
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
>
|
||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||
{modalState.description && !loading && (
|
||||
<ResizableDialogDescription className="text-muted-foreground text-xs">
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{modalState.description}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
)}
|
||||
{loading && (
|
||||
<ResizableDialogDescription className="text-xs">
|
||||
<DialogDescription className="text-xs">
|
||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -728,8 +722,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -137,7 +131,9 @@ export function TableHistoryModal({
|
|||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
|
||||
const date = new Date(dateString);
|
||||
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
|
|
@ -209,7 +205,7 @@ export function TableHistoryModal({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] sm:max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Clock className="h-5 w-5" />
|
||||
변경 이력{" "}
|
||||
{!recordId && (
|
||||
|
|
@ -217,12 +213,12 @@ export function TableHistoryModal({
|
|||
전체
|
||||
</Badge>
|
||||
)}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{recordId
|
||||
? `${recordDisplayValue || recordLabel || "-"} - ${tableName} 테이블`
|
||||
: `${tableName} 테이블 전체 이력`}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -150,23 +150,14 @@ export function TableOptionsModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent
|
||||
defaultWidth={700}
|
||||
defaultHeight={600}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1200}
|
||||
maxHeight={900}
|
||||
modalId={`table-options-${tableName}`}
|
||||
userId={userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">테이블 옵션</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">테이블 옵션</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="columns" className="flex flex-col flex-1 overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-3 flex-shrink-0">
|
||||
|
|
@ -303,7 +294,7 @@ export function TableOptionsModal({
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0 mt-4">
|
||||
<DialogFooter className="gap-2 sm:gap-0 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
|
|
@ -324,9 +315,9 @@ export function TableOptionsModal({
|
|||
>
|
||||
저장
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -673,14 +673,14 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<ResizableDialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent className="max-h-[80vh] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Link className="h-4 w-4" />
|
||||
필드 연결 설정
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기본 연결 설정 */}
|
||||
|
|
@ -719,16 +719,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
{renderConnectionTypeSettings()}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isButtonDisabled()}>
|
||||
연결 생성
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -133,11 +133,11 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-lg font-semibold">📊 관계도 저장</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">📊 관계도 저장</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 관계도 이름 입력 */}
|
||||
|
|
@ -203,7 +203,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
<CardTitle className="text-sm">관계 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 space-y-3 overflow-y-auto">
|
||||
<div className="max-h-60 space-y-3 overflow-hidden">
|
||||
{relationships.map((relationship, index) => (
|
||||
<div
|
||||
key={relationship.id || index}
|
||||
|
|
@ -242,7 +242,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -260,9 +260,9 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
"저장하기"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 저장 성공 알림 모달 */}
|
||||
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
|||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { AggregateNode } from "./nodes/AggregateNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
|
@ -41,6 +42,7 @@ const nodeTypes = {
|
|||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 (Aggregate Node)
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Layers } from "lucide-react";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
// 집계 함수별 아이콘/라벨
|
||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
||||
SUM: "합계",
|
||||
COUNT: "개수",
|
||||
AVG: "평균",
|
||||
MIN: "최소",
|
||||
MAX: "최대",
|
||||
FIRST: "첫번째",
|
||||
LAST: "마지막",
|
||||
};
|
||||
|
||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||
const groupByCount = data.groupByFields?.length || 0;
|
||||
const aggregationCount = data.aggregations?.length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* 그룹 기준 */}
|
||||
{groupByCount > 0 && (
|
||||
<div className="rounded bg-purple-50 p-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Layers className="h-3 w-3 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-700">그룹 기준</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.groupByFields.slice(0, 3).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
{data.groupByFields.length > 3 && (
|
||||
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 집계 연산 */}
|
||||
{aggregationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.aggregations.slice(0, 4).map((agg, idx) => (
|
||||
<div key={agg.id || idx} className="rounded bg-gray-50 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{agg.outputFieldLabel || agg.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{agg.sourceFieldLabel || agg.sourceField}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.aggregations.length > 4 && (
|
||||
<div className="text-xs text-gray-400 text-center">
|
||||
... 외 {data.aggregations.length - 4}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">집계 연산 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AggregateNode.displayName = "AggregateNode";
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
|||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { AggregateProperties } from "./properties/AggregateProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
|
|
@ -122,6 +123,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
|||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "aggregate":
|
||||
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
|
|
@ -157,9 +161,11 @@ function getNodeTypeLabel(type: NodeType): string {
|
|||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
referenceLookup: "참조 조회",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
aggregate: "집계",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 속성 편집 패널
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산 설정
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Calculator, Layers, Filter } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
interface AggregatePropertiesProps {
|
||||
nodeId: string;
|
||||
data: AggregateNodeData;
|
||||
}
|
||||
|
||||
// 집계 함수 옵션
|
||||
const AGGREGATE_FUNCTIONS: Array<{ value: AggregateFunction; label: string; description: string }> = [
|
||||
{ value: "SUM", label: "합계 (SUM)", description: "숫자 필드의 합계를 계산합니다" },
|
||||
{ value: "COUNT", label: "개수 (COUNT)", description: "레코드 개수를 계산합니다" },
|
||||
{ value: "AVG", label: "평균 (AVG)", description: "숫자 필드의 평균을 계산합니다" },
|
||||
{ value: "MIN", label: "최소 (MIN)", description: "최소값을 찾습니다" },
|
||||
{ value: "MAX", label: "최대 (MAX)", description: "최대값을 찾습니다" },
|
||||
{ value: "FIRST", label: "첫번째 (FIRST)", description: "그룹의 첫 번째 값을 가져옵니다" },
|
||||
{ value: "LAST", label: "마지막 (LAST)", description: "그룹의 마지막 값을 가져옵니다" },
|
||||
];
|
||||
|
||||
// 비교 연산자 옵션
|
||||
const OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
];
|
||||
|
||||
export function AggregateProperties({ nodeId, data }: AggregatePropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 로컬 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "집계");
|
||||
const [groupByFields, setGroupByFields] = useState(data.groupByFields || []);
|
||||
const [aggregations, setAggregations] = useState(data.aggregations || []);
|
||||
const [havingConditions, setHavingConditions] = useState(data.havingConditions || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string; type?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "집계");
|
||||
setGroupByFields(data.groupByFields || []);
|
||||
setAggregations(data.aggregations || []);
|
||||
setHavingConditions(data.havingConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string; type?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
type: field.type,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 저장 함수
|
||||
const saveToNode = useCallback(
|
||||
(updates: Partial<AggregateNodeData>) => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
groupByFields,
|
||||
aggregations,
|
||||
havingConditions,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
[nodeId, updateNode, displayName, groupByFields, aggregations, havingConditions]
|
||||
);
|
||||
|
||||
// 그룹 기준 필드 토글
|
||||
const handleGroupByToggle = (fieldName: string, checked: boolean) => {
|
||||
let newGroupByFields;
|
||||
if (checked) {
|
||||
const field = sourceFields.find((f) => f.name === fieldName);
|
||||
newGroupByFields = [...groupByFields, { field: fieldName, fieldLabel: field?.label }];
|
||||
} else {
|
||||
newGroupByFields = groupByFields.filter((f) => f.field !== fieldName);
|
||||
}
|
||||
setGroupByFields(newGroupByFields);
|
||||
saveToNode({ groupByFields: newGroupByFields });
|
||||
};
|
||||
|
||||
// 집계 연산 추가
|
||||
const handleAddAggregation = () => {
|
||||
const newAggregation = {
|
||||
id: `agg_${Date.now()}`,
|
||||
sourceField: "",
|
||||
sourceFieldLabel: "",
|
||||
function: "SUM" as AggregateFunction,
|
||||
outputField: "",
|
||||
outputFieldLabel: "",
|
||||
};
|
||||
const newAggregations = [...aggregations, newAggregation];
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 삭제
|
||||
const handleRemoveAggregation = (index: number) => {
|
||||
const newAggregations = aggregations.filter((_, i) => i !== index);
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 변경
|
||||
const handleAggregationChange = (index: number, field: string, value: any) => {
|
||||
const newAggregations = [...aggregations];
|
||||
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
// 출력 필드명 자동 생성 (예: sum_amount)
|
||||
outputField:
|
||||
newAggregations[index].outputField ||
|
||||
`${newAggregations[index].function.toLowerCase()}_${value}`,
|
||||
};
|
||||
} else if (field === "function") {
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
function: value,
|
||||
// 출력 필드명 업데이트
|
||||
outputField: newAggregations[index].sourceField
|
||||
? `${value.toLowerCase()}_${newAggregations[index].sourceField}`
|
||||
: newAggregations[index].outputField,
|
||||
};
|
||||
} else {
|
||||
newAggregations[index] = { ...newAggregations[index], [field]: value };
|
||||
}
|
||||
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// HAVING 조건 추가
|
||||
const handleAddHavingCondition = () => {
|
||||
const newCondition = {
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
};
|
||||
const newConditions = [...havingConditions, newCondition];
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 삭제
|
||||
const handleRemoveHavingCondition = (index: number) => {
|
||||
const newConditions = havingConditions.filter((_, i) => i !== index);
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 변경
|
||||
const handleHavingConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...havingConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// 집계 결과 필드 목록 (HAVING 조건에서 선택용)
|
||||
const aggregatedFields = aggregations
|
||||
.filter((agg) => agg.outputField)
|
||||
.map((agg) => ({
|
||||
name: agg.outputField,
|
||||
label: agg.outputFieldLabel || agg.outputField,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-purple-50 p-2">
|
||||
<Calculator className="h-4 w-4 text-purple-600" />
|
||||
<span className="font-semibold text-purple-600">집계 노드</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
saveToNode({ displayName: e.target.value });
|
||||
}}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 기준 필드 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">그룹 기준 필드</h3>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
선택한 필드를 기준으로 데이터를 그룹화합니다. 선택하지 않으면 전체 데이터를 하나의 그룹으로 처리합니다.
|
||||
</p>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
<div className="space-y-1">
|
||||
{sourceFields.map((field) => {
|
||||
const isChecked = groupByFields.some((f) => f.field === field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-purple-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`groupby_${field.name}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => handleGroupByToggle(field.name, checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`groupby_${field.name}`}
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="ml-1 text-gray-400">({field.name})</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByFields.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{groupByFields.map((field) => (
|
||||
<span
|
||||
key={field.field}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 집계 연산 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 연산</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddAggregation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연산 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">SUM, COUNT, AVG 등 집계 연산을 설정합니다.</p>
|
||||
|
||||
{aggregations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
집계 연산을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{aggregations.map((agg, index) => (
|
||||
<div key={agg.id || index} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">집계 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveAggregation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 집계 함수 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">집계 함수</Label>
|
||||
<Select
|
||||
value={agg.function}
|
||||
onValueChange={(value) =>
|
||||
handleAggregationChange(index, "function", value as AggregateFunction)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGGREGATE_FUNCTIONS.map((func) => (
|
||||
<SelectItem key={func.value} value={func.value} className="text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{func.label}</div>
|
||||
<div className="text-gray-400">{func.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 소스 필드 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={agg.sourceField || ""}
|
||||
onValueChange={(value) => handleAggregationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="집계할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드명</Label>
|
||||
<Input
|
||||
value={agg.outputField || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputField", e.target.value)}
|
||||
placeholder="예: total_amount"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
집계 결과가 저장될 필드명입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드 라벨 (선택)</Label>
|
||||
<Input
|
||||
value={agg.outputFieldLabel || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputFieldLabel", e.target.value)}
|
||||
placeholder="예: 총 금액"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HAVING 조건 (선택) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 후 필터 (HAVING)</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddHavingCondition}
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={aggregations.length === 0}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">집계 결과에 대한 필터링 조건을 설정합니다 (선택 사항).</p>
|
||||
|
||||
{havingConditions.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-400">
|
||||
집계 후 필터링이 필요하면 조건을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{havingConditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||
{/* 집계 결과 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aggregatedFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 비교값 */}
|
||||
<Input
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => handleHavingConditionChange(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveHavingCondition(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{(groupByFields.length > 0 || aggregations.length > 0) && (
|
||||
<div className="rounded border bg-gray-50 p-3">
|
||||
<h4 className="mb-2 text-xs font-semibold text-gray-700">집계 결과 미리보기</h4>
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">그룹 기준:</span>{" "}
|
||||
{groupByFields.length > 0
|
||||
? groupByFields.map((f) => f.fieldLabel || f.field).join(", ")
|
||||
: "전체 (그룹 없음)"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">집계 컬럼:</span>{" "}
|
||||
{aggregations.length > 0
|
||||
? aggregations
|
||||
.filter((a) => a.outputField)
|
||||
.map((a) => `${a.function}(${a.sourceFieldLabel || a.sourceField}) → ${a.outputFieldLabel || a.outputField}`)
|
||||
.join(", ")
|
||||
: "없음"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +236,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
console.log("✅ 집계 노드 발견");
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`);
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
console.log(` 📊 ${aggregations.length}개 집계 함수 발견`);
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
|
||||
|
|
@ -266,7 +307,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 2️⃣ REST API 소스 노드
|
||||
// 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 3️⃣ REST API 소스 노드
|
||||
else if (node.type === "restAPISource") {
|
||||
foundRestAPI = true;
|
||||
const responseFields = (node.data as any).responseFields;
|
||||
|
|
@ -229,7 +265,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -251,7 +287,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -234,7 +270,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
{
|
||||
type: "aggregate",
|
||||
label: "집계",
|
||||
icon: "",
|
||||
description: "SUM, COUNT, AVG 등 집계 연산을 수행합니다",
|
||||
category: "transform",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
|
@ -130,11 +130,11 @@ export function FlowDataListModal({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{stepName}
|
||||
<Badge variant="secondary">{data.length}건</Badge>
|
||||
</ResizableDialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -48,11 +48,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className={getTypeColor()}>{title}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
|
|
@ -61,8 +61,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,11 +165,11 @@ export function ProfileModal({
|
|||
};
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>프로필 수정</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>프로필 수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* 프로필 사진 섹션 */}
|
||||
|
|
@ -449,16 +449,16 @@ export function ProfileModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
@ -471,14 +471,14 @@ export function ProfileModal({
|
|||
|
||||
{/* 새 차량 등록 모달 */}
|
||||
{isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && (
|
||||
<ResizableDialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
|
||||
<ResizableDialogContent className="sm:max-w-[400px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>새 차량 등록</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
<Dialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 차량 등록</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 차량 정보를 입력해주세요.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -501,16 +501,16 @@ export function ProfileModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onCloseVehicleRegisterModal}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={onRegisterVehicle}>
|
||||
등록
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
DialogContent,
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -186,13 +186,13 @@ export default function MailDetailModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-xl font-bold truncate">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold truncate">
|
||||
메일 상세
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
|
|
@ -375,8 +375,8 @@ export default function MailDetailModal({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -141,9 +141,9 @@ export function LangKeyModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle>{langKey ? "다국어 키 수정" : "새 다국어 키 추가"}</ResizableDialogTitle>
|
||||
<DialogTitle>{langKey ? "다국어 키 수정" : "새 다국어 키 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export function OrderRegistrationModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import { useState, useEffect } from "react";
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -120,8 +119,8 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle>새 리포트 생성</ResizableDialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</ResizableDialogDescription>
|
||||
<DialogTitle>새 리포트 생성</DialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
|
|
@ -207,7 +206,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -221,7 +220,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,10 +92,17 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
}, [initialFormData]);
|
||||
|
||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
useEffect(() => {
|
||||
// 우측 화면인 경우에만 적용
|
||||
if (position !== "right" || !splitPanelContext) return;
|
||||
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedData = splitPanelContext.getMappedParentData();
|
||||
if (Object.keys(mappedData).length > 0) {
|
||||
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
|||
rightScreenId={config?.rightScreenId || null}
|
||||
parentDataMapping={config?.parentDataMapping || []}
|
||||
linkedFilters={config?.linkedFilters || []}
|
||||
disableAutoDataTransfer={config?.disableAutoDataTransfer ?? false}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ export default function CopyScreenModal({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -271,21 +271,11 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
className="sm:max-w-lg"
|
||||
defaultWidth={600}
|
||||
defaultHeight={700}
|
||||
minWidth={500}
|
||||
minHeight={600}
|
||||
maxWidth={900}
|
||||
maxHeight={900}
|
||||
modalId="create-screen"
|
||||
userId={user?.userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>새 화면 생성</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 화면 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -603,15 +593,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="mt-4">
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || submitting} variant="default">
|
||||
생성
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
|
@ -678,14 +678,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
|
|
@ -696,32 +699,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const modalStyle = getModalStyle();
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
style={modalStyle.style}
|
||||
defaultWidth={800}
|
||||
defaultHeight={600}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
maxWidth={1400}
|
||||
maxHeight={1000}
|
||||
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined}
|
||||
userId={user?.userId}
|
||||
>
|
||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||
{modalState.description && !loading && (
|
||||
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
|
||||
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||
)}
|
||||
{loading && (
|
||||
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
|
||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -812,8 +807,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -352,9 +352,9 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
|
|||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<ResizableDialogTitle className="text-xl font-semibold">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
파일 첨부 관리 - {component.label || component.id}
|
||||
</ResizableDialogTitle>
|
||||
</DialogTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -2471,7 +2471,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 기존 데이터 추가 모달 (제거 예정 - SaveModal로 대체됨) */}
|
||||
<Dialog open={false} onOpenChange={() => {}}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{component.addModalConfig?.title || "새 데이터 추가"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -2517,7 +2517,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 기존 데이터 수정 모달 (제거 예정 - SaveModal로 대체됨) */}
|
||||
<Dialog open={false} onOpenChange={() => {}}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>데이터 수정</DialogTitle>
|
||||
<DialogDescription>선택된 데이터를 수정합니다.</DialogDescription>
|
||||
|
|
@ -2773,7 +2773,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 파일 관리 모달 */}
|
||||
<Dialog open={showFileManagementModal} onOpenChange={setShowFileManagementModal}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
|
@ -441,6 +441,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 렉 구조 컴포넌트 처리
|
||||
if (comp.type === "component" && componentType === "rack-structure") {
|
||||
const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent");
|
||||
const componentConfig = (comp as any).componentConfig || {};
|
||||
// config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접
|
||||
const rackConfig = componentConfig.config || componentConfig;
|
||||
|
||||
console.log("🏗️ 렉 구조 컴포넌트 렌더링:", {
|
||||
componentType,
|
||||
componentConfig,
|
||||
rackConfig,
|
||||
fieldMapping: rackConfig.fieldMapping,
|
||||
formData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<RackStructureComponent
|
||||
config={rackConfig}
|
||||
formData={formData}
|
||||
tableName={tableName}
|
||||
onChange={(locations: any[]) => {
|
||||
console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개");
|
||||
// 컴포넌트의 columnName을 키로 사용
|
||||
const fieldKey = (comp as any).columnName || "_rackStructureLocations";
|
||||
updateFormData(fieldKey, locations);
|
||||
}}
|
||||
isPreview={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog";
|
||||
import { DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -119,17 +118,19 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
const splitPanelMappedData = React.useMemo(() => {
|
||||
if (splitPanelContext) {
|
||||
if (splitPanelContext && !splitPanelContext.disableAutoDataTransfer) {
|
||||
return splitPanelContext.getMappedParentData();
|
||||
}
|
||||
return {};
|
||||
}, [splitPanelContext, splitPanelContext?.selectedLeftData]);
|
||||
}, [splitPanelContext, splitPanelContext?.selectedLeftData, splitPanelContext?.disableAutoDataTransfer]);
|
||||
|
||||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합)
|
||||
const formData = React.useMemo(() => {
|
||||
const baseData = externalFormData || localFormData;
|
||||
// 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만)
|
||||
// disableAutoDataTransfer가 true이면 자동 병합 안함
|
||||
if (Object.keys(splitPanelMappedData).length > 0) {
|
||||
const merged = { ...baseData };
|
||||
for (const [key, value] of Object.entries(splitPanelMappedData)) {
|
||||
|
|
@ -776,17 +777,15 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
{popupScreen && (
|
||||
<ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<ResizableDialogContent
|
||||
className="overflow-hidden p-0"
|
||||
defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1600}
|
||||
maxHeight={1200}
|
||||
modalId={`popup-screen-${popupScreen.screenId}`}
|
||||
userId={user?.userId || "guest"}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 max-w-none"
|
||||
style={{
|
||||
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
||||
height: "800px",
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "90vh",
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{popupScreen.title}</DialogTitle>
|
||||
|
|
@ -820,8 +819,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -345,26 +345,26 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{assignmentSuccess ? (
|
||||
// 성공 화면
|
||||
<>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{assignmentMessage.includes("나중에")
|
||||
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
|
||||
: "화면이 성공적으로 메뉴에 할당되었습니다."}
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-green-50 p-4">
|
||||
|
|
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 타이머 정리
|
||||
|
|
@ -407,19 +407,19 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
<Monitor className="mr-2 h-4 w-4" />
|
||||
화면 목록으로 이동
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
// 기본 할당 화면
|
||||
<>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
메뉴에 화면 할당
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
{screenInfo && (
|
||||
<div className="bg-accent mt-2 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -432,7 +432,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||
|
|
@ -550,7 +550,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleAssignLater} disabled={assigning}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
나중에 할당
|
||||
|
|
@ -572,22 +572,22 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 화면 교체 확인 대화상자 */}
|
||||
<ResizableDialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||
<ResizableDialogContent className="max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-orange-600" />
|
||||
화면 교체 확인
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
<DialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기존 화면 목록 */}
|
||||
|
|
@ -628,7 +628,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -652,9 +652,9 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, createContext, useContext } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Monitor, Tablet, Smartphone } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
|
@ -76,7 +76,7 @@ export const ResponsivePreviewModal: React.FC<ResponsivePreviewModalProps> = ({
|
|||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
|
||||
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
||||
<ResizableDialogTitle>반응형 미리보기</ResizableDialogTitle>
|
||||
<DialogTitle>반응형 미리보기</DialogTitle>
|
||||
|
||||
{/* 디바이스 선택 버튼들 */}
|
||||
<div className="mt-4 flex gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Save, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -232,22 +232,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const dynamicSize = calculateDynamicSize();
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<ResizableDialogContent
|
||||
modalId={`save-modal-${screenId}`}
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<DialogContent
|
||||
style={{
|
||||
width: `${dynamicSize.width}px`,
|
||||
height: `${dynamicSize.height}px`, // 화면관리 설정 크기 그대로 사용
|
||||
minWidth: "400px",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
defaultWidth={600} // 폴백용 기본값
|
||||
defaultHeight={400} // 폴백용 기본값
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
className="gap-0 p-0"
|
||||
className="gap-0 p-0 max-w-none"
|
||||
>
|
||||
<ResizableDialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<DialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
||||
{isSaving ? (
|
||||
|
|
@ -267,7 +264,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-auto p-6 flex-1">
|
||||
{loading ? (
|
||||
|
|
@ -376,7 +373,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2239,10 +2239,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
|
||||
});
|
||||
|
||||
// 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스)
|
||||
// 예: "창고코드" → "warehouse_code" 또는 그대로 유지
|
||||
const generateDefaultColumnName = (label: string): string => {
|
||||
// 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능)
|
||||
// 영문의 경우 스네이크 케이스로 변환
|
||||
if (/[가-힣]/.test(label)) {
|
||||
// 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환
|
||||
return label.replace(/\s+/g, "_").toLowerCase();
|
||||
}
|
||||
// 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환
|
||||
return label
|
||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||
.replace(/\s+/g, "_")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
label: component.name,
|
||||
columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성
|
||||
widgetType: component.webType,
|
||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||
position: snappedPosition,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🆕 openModalWithData 전용 필드 매핑 상태
|
||||
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
||||
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -318,6 +326,88 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
|
||||
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const actionType = config.action?.type;
|
||||
if (actionType !== "openModalWithData") return;
|
||||
|
||||
const loadModalMappingColumns = async () => {
|
||||
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
||||
// allComponents에서 split-panel-layout 또는 table-list 찾기
|
||||
let sourceTableName: string | null = null;
|
||||
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
// 분할 패널의 좌측 테이블명
|
||||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
|
||||
(comp as any).componentConfig?.leftTableName;
|
||||
break;
|
||||
}
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = (comp as any).componentConfig?.tableName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalSourceColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 타겟 화면의 테이블 컬럼 로드
|
||||
const targetScreenId = config.action?.targetScreenId;
|
||||
if (targetScreenId) {
|
||||
try {
|
||||
// 타겟 화면 정보 가져오기
|
||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||
if (screenResponse.data.success && screenResponse.data.data) {
|
||||
const targetTableName = screenResponse.data.data.tableName;
|
||||
if (targetTableName) {
|
||||
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||
if (columnResponse.data.success) {
|
||||
let columnData = columnResponse.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalTargetColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadModalMappingColumns();
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
|
|
@ -1024,6 +1114,194 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑 (선택사항)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentMappings = config.action?.fieldMappings || [];
|
||||
const newMapping = { sourceField: "", targetField: "" };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", [...currentMappings, newMapping]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
|
||||
<br />
|
||||
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑)
|
||||
</p>
|
||||
|
||||
{/* 컬럼 로드 상태 표시 */}
|
||||
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-amber-600 bg-amber-50 p-2 rounded dark:bg-amber-950/20">
|
||||
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.action?.fieldMappings || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={modalSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={modalSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], sourceField: col.name };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={modalTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.targetField
|
||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "타겟 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={modalTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], targetField: col.name };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -584,20 +584,23 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedComponent.type === "widget" && (
|
||||
{(selectedComponent.type === "widget" || selectedComponent.type === "component") && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="columnName" className="text-xs font-medium">
|
||||
컬럼명 (읽기 전용)
|
||||
컬럼명 (필드명)
|
||||
</Label>
|
||||
<Input
|
||||
id="columnName"
|
||||
value={selectedComponent.columnName || ""}
|
||||
readOnly
|
||||
placeholder="데이터베이스 컬럼명"
|
||||
className="bg-muted/50 text-muted-foreground h-8"
|
||||
title="컬럼명은 변경할 수 없습니다"
|
||||
onChange={(e) => onUpdateProperty("columnName", e.target.value)}
|
||||
placeholder="formData에서 사용할 필드명"
|
||||
className="h-8"
|
||||
title="분할 패널에서 데이터를 전달받을 때 사용되는 필드명입니다"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
분할 패널에서 데이터를 전달받을 때 매핑되는 필드명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, DialogFooter, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 대시보드
|
||||
* 구매/설치/수리/유지보수/폐기 등 비용 정산 현황
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Package,
|
||||
Wrench,
|
||||
Settings,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice";
|
||||
|
||||
// 비용 유형별 아이콘
|
||||
const costTypeIcons: Record<CostType, React.ReactNode> = {
|
||||
purchase: <Package className="h-4 w-4" />,
|
||||
installation: <Settings className="h-4 w-4" />,
|
||||
repair: <Wrench className="h-4 w-4" />,
|
||||
maintenance: <Settings className="h-4 w-4" />,
|
||||
disposal: <Trash2 className="h-4 w-4" />,
|
||||
other: <DollarSign className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
// 비용 유형별 색상
|
||||
const costTypeColors: Record<CostType, string> = {
|
||||
purchase: "bg-blue-500",
|
||||
installation: "bg-green-500",
|
||||
repair: "bg-orange-500",
|
||||
maintenance: "bg-purple-500",
|
||||
disposal: "bg-red-500",
|
||||
other: "bg-gray-500",
|
||||
};
|
||||
|
||||
export function CostTypeStats() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<CostTypeStatsResponse["data"] | null>(null);
|
||||
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(undefined);
|
||||
|
||||
// 연도 옵션 생성 (최근 5년)
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||
|
||||
// 월 옵션 생성
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
// 데이터 로드
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCostTypeStats(selectedYear, selectedMonth);
|
||||
if (response.success) {
|
||||
setStats(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("통계 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedYear, selectedMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
if (amount >= 100000000) {
|
||||
return `${(amount / 100000000).toFixed(1)}억`;
|
||||
}
|
||||
if (amount >= 10000) {
|
||||
return `${(amount / 10000).toFixed(0)}만`;
|
||||
}
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 전체 금액 대비 비율 계산
|
||||
const getPercentage = (amount: number) => {
|
||||
if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0;
|
||||
return (amount / stats.summary.total_amount) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">비용 정산 현황</h2>
|
||||
<p className="text-muted-foreground text-sm">구매/설치/수리/유지보수/폐기 비용 통계</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(selectedYear)}
|
||||
onValueChange={(v) => setSelectedYear(parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}년
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={selectedMonth ? String(selectedMonth) : "all"}
|
||||
onValueChange={(v) => setSelectedMonth(v === "all" ? undefined : parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{monthOptions.map((month) => (
|
||||
<SelectItem key={month} value={String(month)}>
|
||||
{month}월
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={loadStats} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 비용</CardTitle>
|
||||
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount(stats?.summary.total_amount || 0)}원
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{stats?.summary.total_count || 0}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">구매 비용</CardTitle>
|
||||
<Package className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount(stats?.summary.purchase_amount || 0)}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage(stats?.summary.purchase_amount || 0)}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">수리/유지보수</CardTitle>
|
||||
<Wrench className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">설치/폐기</CardTitle>
|
||||
<Settings className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원
|
||||
</div>
|
||||
<Progress
|
||||
value={getPercentage((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 비용 유형별 상세 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>비용 유형별 상세</CardTitle>
|
||||
<CardDescription>각 비용 유형별 금액 및 비율</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{stats?.by_cost_type && stats.by_cost_type.length > 0 ? (
|
||||
stats.by_cost_type.map((item) => {
|
||||
const costType = item.cost_type as CostType;
|
||||
const percentage = getPercentage(item.total_amount);
|
||||
return (
|
||||
<div key={costType || "none"} className="flex items-center gap-4">
|
||||
<div className="flex w-[120px] items-center gap-2">
|
||||
{costType && costTypeIcons[costType]}
|
||||
<span className="text-sm font-medium">
|
||||
{costType ? costTypeLabels[costType] : "미분류"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${costType ? costTypeColors[costType] : "bg-gray-400"}`}
|
||||
style={{ width: `${Math.max(percentage, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-[50px] text-right text-sm text-muted-foreground">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[120px] text-right">
|
||||
<div className="font-mono text-sm font-semibold">
|
||||
{formatAmount(item.total_amount)}원
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.count}건</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 월별 추이 */}
|
||||
{!selectedMonth && stats?.by_month && stats.by_month.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>월별 비용 추이</CardTitle>
|
||||
<CardDescription>{selectedYear}년 월별 비용 현황</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 월별 그룹핑 */}
|
||||
{Array.from(new Set(stats.by_month.map((item) => item.year_month)))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 6)
|
||||
.map((yearMonth) => {
|
||||
const monthData = stats.by_month.filter((item) => item.year_month === yearMonth);
|
||||
const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0);
|
||||
const [year, month] = yearMonth.split("-");
|
||||
|
||||
return (
|
||||
<div key={yearMonth} className="flex items-center gap-4 py-2 border-b last:border-0">
|
||||
<div className="w-[80px] text-sm font-medium">
|
||||
{month}월
|
||||
</div>
|
||||
<div className="flex-1 flex gap-1">
|
||||
{monthData.map((item) => {
|
||||
const costType = item.cost_type as CostType;
|
||||
const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={costType || "none"}
|
||||
className={`h-6 ${costType ? costTypeColors[costType] : "bg-gray-400"} rounded`}
|
||||
style={{ width: `${Math.max(width, 5)}%` }}
|
||||
title={`${costType ? costTypeLabels[costType] : "미분류"}: ${formatAmount(item.total_amount)}원`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-[100px] text-right font-mono text-sm">
|
||||
{formatAmount(monthTotal)}원
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="mt-4 flex flex-wrap gap-3 pt-4 border-t">
|
||||
{Object.entries(costTypeLabels).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={`w-3 h-3 rounded ${costTypeColors[key as CostType]}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 보기 컴포넌트
|
||||
* PDF 출력 및 첨부파일 다운로드 기능 포함
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import {
|
||||
Printer,
|
||||
Download,
|
||||
FileText,
|
||||
Image,
|
||||
File,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getTaxInvoiceById,
|
||||
TaxInvoice,
|
||||
TaxInvoiceItem,
|
||||
TaxInvoiceAttachment,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface TaxInvoiceDetailProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "임시저장",
|
||||
issued: "발행완료",
|
||||
sent: "전송완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-gray-100 text-gray-800",
|
||||
issued: "bg-green-100 text-green-800",
|
||||
sent: "bg-blue-100 text-blue-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
|
||||
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
|
||||
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open && invoiceId) {
|
||||
loadData();
|
||||
}
|
||||
}, [open, invoiceId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTaxInvoiceById(invoiceId);
|
||||
if (response.success) {
|
||||
setInvoice(response.data.invoice);
|
||||
setItems(response.data.items);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
|
||||
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
|
||||
// objid가 숫자형이면 API를 통해 미리보기
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
// apiClient의 baseURL 사용
|
||||
const baseURL = apiClient.defaults.baseURL || "";
|
||||
return `${baseURL}/files/preview/${attachment.id}`;
|
||||
}
|
||||
return attachment.file_path;
|
||||
};
|
||||
|
||||
// 공통 인쇄용 HTML 생성 함수
|
||||
const generatePrintHtml = (autoPrint: boolean = false) => {
|
||||
if (!invoice) return "";
|
||||
|
||||
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
|
||||
const itemsHtml = items.map((item, index) => `
|
||||
<tr>
|
||||
<td style="text-align:center">${index + 1}</td>
|
||||
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
|
||||
<td>${item.item_name}</td>
|
||||
<td>${item.item_spec || "-"}</td>
|
||||
<td style="text-align:right">${item.quantity}</td>
|
||||
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
|
||||
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>세금계산서_${invoice.invoice_number}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
|
||||
.header h1 { font-size: 28px; margin-bottom: 10px; }
|
||||
.header .invoice-number { font-size: 14px; color: #666; }
|
||||
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
|
||||
.status-draft { background: #f3f4f6; color: #374151; }
|
||||
.status-issued { background: #d1fae5; color: #065f46; }
|
||||
.status-sent { background: #dbeafe; color: #1e40af; }
|
||||
.status-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
|
||||
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||||
.party-label { width: 80px; color: #666; }
|
||||
.party-value { flex: 1; }
|
||||
.items-section { margin-bottom: 30px; }
|
||||
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
.total-section { display: flex; justify-content: flex-end; }
|
||||
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
|
||||
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
|
||||
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
|
||||
.total-row.grand .value { color: #1d4ed8; }
|
||||
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
|
||||
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
|
||||
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
|
||||
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
|
||||
.attachments ul { list-style: none; font-size: 12px; }
|
||||
.attachments li { padding: 4px 0; }
|
||||
@media print {
|
||||
body { padding: 15px; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>세금계산서 (${invoiceTypeText})</h1>
|
||||
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
|
||||
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<h3>공급자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">업태/종목</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
|
||||
</div>
|
||||
<div class="party">
|
||||
<h3>공급받는자</h3>
|
||||
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">이메일</span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
|
||||
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<h3>품목 내역</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">No</th>
|
||||
<th style="width:80px">일자</th>
|
||||
<th>품목명</th>
|
||||
<th style="width:70px">규격</th>
|
||||
<th style="width:50px">수량</th>
|
||||
<th style="width:80px">단가</th>
|
||||
<th style="width:90px">공급가액</th>
|
||||
<th style="width:70px">세액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="total-section">
|
||||
<div class="total-box">
|
||||
<div class="total-row"><span>공급가액</span><span>${formatAmount(invoice.supply_amount)}원</span></div>
|
||||
<div class="total-row"><span>세액</span><span>${formatAmount(invoice.tax_amount)}원</span></div>
|
||||
<div class="total-row grand"><span>합계금액</span><span class="value">${formatAmount(invoice.total_amount)}원</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
|
||||
|
||||
${invoice.attachments && invoice.attachments.length > 0 ? `
|
||||
<div class="attachments">
|
||||
<h3>첨부파일 (${invoice.attachments.length}개)</h3>
|
||||
<ul>
|
||||
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="footer">
|
||||
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
|
||||
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
if (!invoice) return;
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
};
|
||||
|
||||
// PDF 다운로드 (인쇄 다이얼로그 사용)
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoice) return;
|
||||
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(generatePrintHtml(true));
|
||||
printWindow.document.close();
|
||||
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
|
||||
} catch (error: any) {
|
||||
console.error("PDF 생성 오류:", error);
|
||||
toast.error("PDF 생성 실패", { description: error.message });
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 아이콘
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
// 파일 다운로드 (인증 토큰 포함)
|
||||
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
|
||||
try {
|
||||
// objid가 숫자형이면 API를 통해 다운로드
|
||||
if (attachment.id && !attachment.id.includes("-")) {
|
||||
const response = await apiClient.get(`/files/download/${attachment.id}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
// Blob으로 다운로드
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = attachment.file_name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// 직접 경로로 다운로드
|
||||
window.open(attachment.file_path, "_blank");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("파일 다운로드 실패", { description: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
|
||||
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
|
||||
<DialogTitle>세금계산서 상세</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
>
|
||||
{pdfLoading ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)]">
|
||||
<div className="p-6" ref={printRef}>
|
||||
<div className="invoice-container">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="mb-2 text-2xl font-bold">
|
||||
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
계산서번호: {invoice.invoice_number}
|
||||
</p>
|
||||
<Badge className={statusColors[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 공급자 / 공급받는자 정보 */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-6">
|
||||
{/* 공급자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.supplier_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.supplier_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.supplier_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">업태/종목</span>
|
||||
<span>
|
||||
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.supplier_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급받는자 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="mb-3 border-b pb-2 font-semibold">공급받는자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">사업자번호</span>
|
||||
<span>{invoice.buyer_business_no || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">상호</span>
|
||||
<span>{invoice.buyer_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">대표자</span>
|
||||
<span>{invoice.buyer_ceo_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">이메일</span>
|
||||
<span>{invoice.buyer_email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground w-24">주소</span>
|
||||
<span className="flex-1">{invoice.buyer_address || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold">품목 내역</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
|
||||
<TableCell>{item.item_name}</TableCell>
|
||||
<TableCell>{item.item_spec || "-"}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.unit_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(item.tax_amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
|
||||
품목 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.supply_amount)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(invoice.tax_amount)}원</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>합계금액</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(invoice.total_amount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
{invoice.remarks && (
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-2 font-semibold">비고</h3>
|
||||
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
|
||||
{invoice.remarks}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜 정보 */}
|
||||
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
|
||||
<span>작성일: {formatDate(invoice.invoice_date)}</span>
|
||||
{invoice.issue_date && <span>발행일: {formatDate(invoice.issue_date)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{invoice.attachments && invoice.attachments.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<Separator className="mb-4" />
|
||||
<h3 className="mb-3 font-semibold">첨부파일 ({invoice.attachments.length}개)</h3>
|
||||
|
||||
{/* 이미지 미리보기 */}
|
||||
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{invoice.attachments
|
||||
.filter((f) => f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
|
||||
>
|
||||
<img
|
||||
src={getFilePreviewUrl(file)}
|
||||
alt={file.file_name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="w-full p-2">
|
||||
<p className="truncate text-xs text-white">{file.file_name}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기타 파일 목록 */}
|
||||
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
|
||||
<div className="space-y-2">
|
||||
{invoice.attachments
|
||||
.filter((f) => !f.file_type?.startsWith("image/"))
|
||||
.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<span className="text-sm">{file.file_name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 작성/수정 폼
|
||||
* 파일 첨부 기능 포함
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
Image,
|
||||
File,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
createTaxInvoice,
|
||||
updateTaxInvoice,
|
||||
getTaxInvoiceById,
|
||||
TaxInvoice,
|
||||
TaxInvoiceAttachment,
|
||||
CreateTaxInvoiceDto,
|
||||
CreateTaxInvoiceItemDto,
|
||||
CostType,
|
||||
costTypeLabels,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface TaxInvoiceFormProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
invoice?: TaxInvoice | null;
|
||||
}
|
||||
|
||||
// 품목 초기값
|
||||
const emptyItem: CreateTaxInvoiceItemDto = {
|
||||
item_date: format(new Date(), "yyyy-MM-dd"),
|
||||
item_name: "",
|
||||
item_spec: "",
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
remarks: "",
|
||||
};
|
||||
|
||||
export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) {
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<CreateTaxInvoiceDto>({
|
||||
invoice_type: "sales",
|
||||
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
total_amount: 0,
|
||||
items: [{ ...emptyItem }],
|
||||
});
|
||||
|
||||
// 첨부파일 상태
|
||||
const [attachments, setAttachments] = useState<TaxInvoiceAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
// 수정 모드일 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (invoice) {
|
||||
loadInvoiceData(invoice.id);
|
||||
} else {
|
||||
// 새 세금계산서
|
||||
setFormData({
|
||||
invoice_type: "sales",
|
||||
invoice_date: format(new Date(), "yyyy-MM-dd"),
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
total_amount: 0,
|
||||
items: [{ ...emptyItem }],
|
||||
});
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
// 세금계산서 데이터 로드
|
||||
const loadInvoiceData = async (id: string) => {
|
||||
try {
|
||||
const response = await getTaxInvoiceById(id);
|
||||
if (response.success) {
|
||||
const { invoice: inv, items } = response.data;
|
||||
setFormData({
|
||||
invoice_type: inv.invoice_type,
|
||||
invoice_date: inv.invoice_date?.split("T")[0] || "",
|
||||
supplier_business_no: inv.supplier_business_no,
|
||||
supplier_name: inv.supplier_name,
|
||||
supplier_ceo_name: inv.supplier_ceo_name,
|
||||
supplier_address: inv.supplier_address,
|
||||
supplier_business_type: inv.supplier_business_type,
|
||||
supplier_business_item: inv.supplier_business_item,
|
||||
buyer_business_no: inv.buyer_business_no,
|
||||
buyer_name: inv.buyer_name,
|
||||
buyer_ceo_name: inv.buyer_ceo_name,
|
||||
buyer_address: inv.buyer_address,
|
||||
buyer_email: inv.buyer_email,
|
||||
supply_amount: inv.supply_amount,
|
||||
tax_amount: inv.tax_amount,
|
||||
total_amount: inv.total_amount,
|
||||
remarks: inv.remarks,
|
||||
cost_type: inv.cost_type || undefined,
|
||||
items:
|
||||
items.length > 0
|
||||
? items.map((item) => ({
|
||||
item_date: item.item_date?.split("T")[0] || "",
|
||||
item_name: item.item_name,
|
||||
item_spec: item.item_spec,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
supply_amount: item.supply_amount,
|
||||
tax_amount: item.tax_amount,
|
||||
remarks: item.remarks,
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
setAttachments(inv.attachments || []);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 변경
|
||||
const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 품목 변경
|
||||
const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const items = [...(prev.items || [])];
|
||||
items[index] = { ...items[index], [field]: value };
|
||||
|
||||
// 공급가액 자동 계산
|
||||
if (field === "quantity" || field === "unit_price") {
|
||||
const qty = field === "quantity" ? value : items[index].quantity;
|
||||
const price = field === "unit_price" ? value : items[index].unit_price;
|
||||
items[index].supply_amount = qty * price;
|
||||
items[index].tax_amount = Math.round(items[index].supply_amount * 0.1);
|
||||
}
|
||||
|
||||
// 총액 재계산
|
||||
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
items,
|
||||
supply_amount: totalSupply,
|
||||
tax_amount: totalTax,
|
||||
total_amount: totalSupply + totalTax,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 품목 추가
|
||||
const handleAddItem = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: [...(prev.items || []), { ...emptyItem }],
|
||||
}));
|
||||
};
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = (index: number) => {
|
||||
setFormData((prev) => {
|
||||
const items = (prev.items || []).filter((_, i) => i !== index);
|
||||
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
|
||||
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
items: items.length > 0 ? items : [{ ...emptyItem }],
|
||||
supply_amount: totalSupply,
|
||||
tax_amount: totalTax,
|
||||
total_amount: totalSupply + totalTax,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
|
||||
formDataUpload.append("category", "tax-invoice");
|
||||
|
||||
const response = await apiClient.post("/files/upload", formDataUpload, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.files?.length > 0) {
|
||||
const uploadedFile = response.data.files[0];
|
||||
const newAttachment: TaxInvoiceAttachment = {
|
||||
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file_name: uploadedFile.realFileName || file.name,
|
||||
file_path: uploadedFile.filePath,
|
||||
file_size: uploadedFile.fileSize || file.size,
|
||||
file_type: file.type,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
uploaded_by: "",
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
toast.success(`'${file.name}' 업로드 완료`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("파일 업로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// input 초기화
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 첨부파일 삭제
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
// 파일 아이콘
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
|
||||
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
// 파일 크기 포맷
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.invoice_date) {
|
||||
toast.error("작성일자를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
attachments,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (invoice) {
|
||||
response = await updateTaxInvoice(invoice.id, dataToSave);
|
||||
} else {
|
||||
response = await createTaxInvoice(dataToSave);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message || "저장되었습니다.");
|
||||
onSave();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("저장 실패", { description: error.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[900px] overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{invoice ? "세금계산서 수정" : "세금계산서 작성"}</DialogTitle>
|
||||
<DialogDescription>세금계산서 정보를 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-180px)]">
|
||||
<div className="space-y-4 p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">기본정보</TabsTrigger>
|
||||
<TabsTrigger value="supplier">공급자</TabsTrigger>
|
||||
<TabsTrigger value="buyer">공급받는자</TabsTrigger>
|
||||
<TabsTrigger value="attachments">
|
||||
첨부파일
|
||||
{attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본정보 탭 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">유형 *</Label>
|
||||
<Select
|
||||
value={formData.invoice_type}
|
||||
onValueChange={(v) => handleChange("invoice_type", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">비용 유형</Label>
|
||||
<Select
|
||||
value={formData.cost_type || "none"}
|
||||
onValueChange={(v) => handleChange("cost_type", v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{Object.entries(costTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작성일자 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.invoice_date}
|
||||
onChange={(e) => handleChange("invoice_date", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input
|
||||
value={formData.remarks || ""}
|
||||
onChange={(e) => handleChange("remarks", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">품목 내역</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">일자</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[80px] text-right">세액</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(formData.items || []).map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.item_date || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_date", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_name || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_name", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="품목명"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={item.item_spec || ""}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "item_spec", e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.unit_price || 0}
|
||||
onChange={(e) =>
|
||||
handleItemChange(
|
||||
index,
|
||||
"unit_price",
|
||||
parseFloat(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
className="h-8 text-right text-xs"
|
||||
min={0}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.supply_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right font-mono text-xs">
|
||||
{formatAmount(item.tax_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
disabled={(formData.items?.length || 0) <= 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-[300px] space-y-2 rounded-lg border p-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">공급가액</span>
|
||||
<span className="font-mono">{formatAmount(formData.supply_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">세액</span>
|
||||
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2 text-lg font-bold">
|
||||
<span>합계</span>
|
||||
<span className="font-mono text-primary">
|
||||
{formatAmount(formData.total_amount || 0)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급자 탭 */}
|
||||
<TabsContent value="supplier" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_no || ""}
|
||||
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.supplier_name || ""}
|
||||
onChange={(e) => handleChange("supplier_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.supplier_ceo_name || ""}
|
||||
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">업태</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_type || ""}
|
||||
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="업태"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종목</Label>
|
||||
<Input
|
||||
value={formData.supplier_business_item || ""}
|
||||
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="종목"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.supplier_address || ""}
|
||||
onChange={(e) => handleChange("supplier_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 공급받는자 탭 */}
|
||||
<TabsContent value="buyer" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">사업자등록번호</Label>
|
||||
<Input
|
||||
value={formData.buyer_business_no || ""}
|
||||
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상호</Label>
|
||||
<Input
|
||||
value={formData.buyer_name || ""}
|
||||
onChange={(e) => handleChange("buyer_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="상호명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">대표자명</Label>
|
||||
<Input
|
||||
value={formData.buyer_ceo_name || ""}
|
||||
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="대표자명"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">이메일</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.buyer_email || ""}
|
||||
onChange={(e) => handleChange("buyer_email", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
value={formData.buyer_address || ""}
|
||||
onChange={(e) => handleChange("buyer_address", e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 첨부파일 탭 */}
|
||||
<TabsContent value="attachments" className="space-y-4">
|
||||
{/* 업로드 영역 */}
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex cursor-pointer flex-col items-center gap-2"
|
||||
>
|
||||
<Upload className="text-muted-foreground h-8 w-8" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
PDF, 이미지, 문서 파일 (최대 10MB)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 목록 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">첨부된 파일 ({attachments.length}개)</Label>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(file.file_type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.file_name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(file.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveAttachment(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
첨부된 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 컴포넌트
|
||||
* 세금계산서 목록 조회, 검색, 필터링 기능
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
FileText,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Paperclip,
|
||||
Image,
|
||||
File,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getTaxInvoiceList,
|
||||
deleteTaxInvoice,
|
||||
issueTaxInvoice,
|
||||
cancelTaxInvoice,
|
||||
TaxInvoice,
|
||||
TaxInvoiceListParams,
|
||||
CostType,
|
||||
costTypeLabels,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||
|
||||
// 상태 뱃지 색상
|
||||
const statusBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
issued: "default",
|
||||
sent: "secondary",
|
||||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
// 상태 라벨
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "임시저장",
|
||||
issued: "발행완료",
|
||||
sent: "전송완료",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
// 유형 라벨
|
||||
const typeLabels: Record<string, string> = {
|
||||
sales: "매출",
|
||||
purchase: "매입",
|
||||
};
|
||||
|
||||
// 컬럼 정의
|
||||
interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: "text" | "select";
|
||||
filterOptions?: { value: string; label: string }[];
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
const columns: ColumnDef[] = [
|
||||
{ key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" },
|
||||
{ key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "70px" },
|
||||
{ key: "cost_type", label: "비용유형", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: Object.entries(costTypeLabels).map(([value, label]) => ({ value, label })), width: "90px" },
|
||||
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
|
||||
filterOptions: [
|
||||
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
|
||||
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
|
||||
], width: "90px" },
|
||||
{ key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" },
|
||||
{ key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" },
|
||||
{ key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" },
|
||||
{ key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" },
|
||||
{ key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" },
|
||||
{ key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" },
|
||||
];
|
||||
|
||||
export function TaxInvoiceList() {
|
||||
// 상태
|
||||
const [invoices, setInvoices] = useState<TaxInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState<TaxInvoiceListParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 정렬 상태
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||
|
||||
// 컬럼별 필터 상태
|
||||
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
|
||||
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
|
||||
|
||||
// 모달 상태
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<TaxInvoice | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// 확인 다이얼로그 상태
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
type: "delete" | "issue" | "cancel";
|
||||
invoice: TaxInvoice | null;
|
||||
}>({
|
||||
open: false,
|
||||
type: "delete",
|
||||
invoice: null,
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 컬럼 필터를 API 파라미터에 추가
|
||||
const apiFilters: TaxInvoiceListParams = {
|
||||
...filters,
|
||||
invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined,
|
||||
invoice_status: columnFilters.invoice_status,
|
||||
cost_type: columnFilters.cost_type as CostType | undefined,
|
||||
search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined,
|
||||
};
|
||||
|
||||
const response = await getTaxInvoiceList(apiFilters);
|
||||
if (response.success) {
|
||||
let data = response.data;
|
||||
|
||||
// 클라이언트 사이드 정렬 적용
|
||||
if (sortConfig) {
|
||||
data = [...data].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key as keyof TaxInvoice];
|
||||
const bVal = b[sortConfig.key as keyof TaxInvoice];
|
||||
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
// 숫자 비교
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
const strA = String(aVal).toLowerCase();
|
||||
const strB = String(bVal).toLowerCase();
|
||||
if (sortConfig.direction === "asc") {
|
||||
return strA.localeCompare(strB, "ko");
|
||||
}
|
||||
return strB.localeCompare(strA, "ko");
|
||||
});
|
||||
}
|
||||
|
||||
// 클라이언트 사이드 필터 적용 (날짜 필터)
|
||||
if (columnFilters.invoice_date) {
|
||||
data = data.filter((item) =>
|
||||
item.invoice_date?.includes(columnFilters.invoice_date)
|
||||
);
|
||||
}
|
||||
|
||||
setInvoices(data);
|
||||
setPagination(response.pagination);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("데이터 로드 실패", { description: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, sortConfig, columnFilters, searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 정렬 핸들러
|
||||
const handleSort = (columnKey: string) => {
|
||||
setSortConfig((prev) => {
|
||||
if (prev?.key === columnKey) {
|
||||
// 같은 컬럼 클릭: asc -> desc -> null 순환
|
||||
if (prev.direction === "asc") return { key: columnKey, direction: "desc" };
|
||||
return null;
|
||||
}
|
||||
// 새 컬럼: asc로 시작
|
||||
return { key: columnKey, direction: "asc" };
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 필터 핸들러
|
||||
const handleColumnFilter = (columnKey: string, value: string) => {
|
||||
setColumnFilters((prev) => {
|
||||
if (!value) {
|
||||
const { [columnKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [columnKey]: value };
|
||||
});
|
||||
setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const clearColumnFilter = (columnKey: string) => {
|
||||
setColumnFilters((prev) => {
|
||||
const { [columnKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setActiveFilterColumn(null);
|
||||
};
|
||||
|
||||
// 모든 필터 초기화
|
||||
const clearAllFilters = () => {
|
||||
setColumnFilters({});
|
||||
setSortConfig(null);
|
||||
setSearchText("");
|
||||
setFilters({ page: 1, pageSize: 20 });
|
||||
};
|
||||
|
||||
// 정렬 아이콘 렌더링
|
||||
const renderSortIcon = (columnKey: string) => {
|
||||
if (sortConfig?.key !== columnKey) {
|
||||
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-30" />;
|
||||
}
|
||||
return sortConfig.direction === "asc"
|
||||
? <ArrowUp className="ml-1 h-3 w-3 text-primary" />
|
||||
: <ArrowDown className="ml-1 h-3 w-3 text-primary" />;
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
setFilters((prev) => ({ ...prev, search: searchText, page: 1 }));
|
||||
};
|
||||
|
||||
// 필터 변경
|
||||
const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[key]: value === "all" ? undefined : value,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
// 새 세금계산서
|
||||
const handleNew = () => {
|
||||
setSelectedInvoice(null);
|
||||
setEditMode(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = (invoice: TaxInvoice) => {
|
||||
setSelectedInvoice(invoice);
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
// 수정
|
||||
const handleEdit = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setSelectedInvoice(invoice);
|
||||
setEditMode(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "delete", invoice });
|
||||
};
|
||||
|
||||
// 발행 확인
|
||||
const handleIssueConfirm = (invoice: TaxInvoice) => {
|
||||
if (invoice.invoice_status !== "draft") {
|
||||
toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "issue", invoice });
|
||||
};
|
||||
|
||||
// 취소 확인
|
||||
const handleCancelConfirm = (invoice: TaxInvoice) => {
|
||||
if (!["draft", "issued"].includes(invoice.invoice_status)) {
|
||||
toast.warning("취소할 수 없는 상태입니다.");
|
||||
return;
|
||||
}
|
||||
setConfirmDialog({ open: true, type: "cancel", invoice });
|
||||
};
|
||||
|
||||
// 확인 다이얼로그 실행
|
||||
const handleConfirmAction = async () => {
|
||||
const { type, invoice } = confirmDialog;
|
||||
if (!invoice) return;
|
||||
|
||||
try {
|
||||
if (type === "delete") {
|
||||
const response = await deleteTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 삭제되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
} else if (type === "issue") {
|
||||
const response = await issueTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 발행되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
} else if (type === "cancel") {
|
||||
const response = await cancelTaxInvoice(invoice.id);
|
||||
if (response.success) {
|
||||
toast.success("세금계산서가 취소되었습니다.");
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error("작업 실패", { description: error.message });
|
||||
} finally {
|
||||
setConfirmDialog({ open: false, type: "delete", invoice: null });
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 저장 완료
|
||||
const handleFormSave = () => {
|
||||
setShowForm(false);
|
||||
setSelectedInvoice(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat("ko-KR").format(amount);
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy-MM-dd", { locale: ko });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">세금계산서 관리</h1>
|
||||
<Button onClick={handleNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
새 세금계산서
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{/* 검색 */}
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label className="text-xs">검색</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="계산서번호, 거래처명 검색"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유형 필터 */}
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs">유형</Label>
|
||||
<Select
|
||||
value={filters.invoice_type || "all"}
|
||||
onValueChange={(v) => handleFilterChange("invoice_type", v as "sales" | "purchase")}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="sales">매출</SelectItem>
|
||||
<SelectItem value="purchase">매입</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select
|
||||
value={filters.invoice_status || "all"}
|
||||
onValueChange={(v) => handleFilterChange("invoice_status", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="draft">임시저장</SelectItem>
|
||||
<SelectItem value="issued">발행완료</SelectItem>
|
||||
<SelectItem value="sent">전송완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 */}
|
||||
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{(Object.keys(columnFilters).length > 0 || sortConfig) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="text-muted-foreground">
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 표시 */}
|
||||
{Object.keys(columnFilters).length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(columnFilters).map(([key, value]) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
let displayValue = value;
|
||||
if (column?.filterOptions) {
|
||||
displayValue = column.filterOptions.find((o) => o.value === value)?.label || value;
|
||||
}
|
||||
return (
|
||||
<Badge key={key} variant="secondary" className="gap-1 pr-1">
|
||||
{column?.label}: {displayValue}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => clearColumnFilter(key)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={`
|
||||
${column.width ? `w-[${column.width}]` : ""}
|
||||
${column.align === "center" ? "text-center" : ""}
|
||||
${column.align === "right" ? "text-right" : ""}
|
||||
`}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${column.align === "right" ? "justify-end" : column.align === "center" ? "justify-center" : ""}`}>
|
||||
{/* 컬럼 필터 (filterable인 경우) */}
|
||||
{column.filterable && (
|
||||
<Popover
|
||||
open={activeFilterColumn === column.key}
|
||||
onOpenChange={(open) => setActiveFilterColumn(open ? column.key : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 p-0 ${columnFilters[column.key] ? "text-primary" : "text-muted-foreground opacity-50 hover:opacity-100"}`}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2" align="start">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{column.label} 필터</div>
|
||||
{column.filterType === "select" ? (
|
||||
<Select
|
||||
value={columnFilters[column.key] || ""}
|
||||
onValueChange={(v) => {
|
||||
handleColumnFilter(column.key, v);
|
||||
setActiveFilterColumn(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.filterOptions?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={`${column.label} 검색...`}
|
||||
value={columnFilters[column.key] || ""}
|
||||
onChange={(e) => handleColumnFilter(column.key, e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)}
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{columnFilters[column.key] && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => clearColumnFilter(column.key)}
|
||||
>
|
||||
필터 지우기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* 컬럼 라벨 + 정렬 */}
|
||||
{column.sortable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 font-medium hover:bg-transparent"
|
||||
onClick={() => handleSort(column.key)}
|
||||
>
|
||||
{column.label}
|
||||
{renderSortIcon(column.key)}
|
||||
</Button>
|
||||
) : (
|
||||
<span>{column.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : invoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
|
||||
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
세금계산서가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id} className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-sm">{invoice.invoice_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={invoice.invoice_type === "sales" ? "default" : "secondary"}>
|
||||
{typeLabels[invoice.invoice_type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.cost_type ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{costTypeLabels[invoice.cost_type as CostType]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
|
||||
{statusLabels[invoice.invoice_status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{invoice.buyer_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{invoice.attachments && invoice.attachments.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{invoice.attachments.length}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.supply_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatAmount(invoice.tax_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">
|
||||
{formatAmount(invoice.total_amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleView(invoice)}
|
||||
title="상세보기"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{invoice.invoice_status === "draft" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(invoice)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleIssueConfirm(invoice)}
|
||||
title="발행"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDeleteConfirm(invoice)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.invoice_status === "issued" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-orange-600"
|
||||
onClick={() => handleCancelConfirm(invoice)}
|
||||
title="취소"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
총 {pagination.total}건 중 {(pagination.page - 1) * pagination.pageSize + 1}-
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.total)}건
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 작성/수정 폼 */}
|
||||
{showForm && (
|
||||
<TaxInvoiceForm
|
||||
open={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSave={handleFormSave}
|
||||
invoice={editMode ? selectedInvoice : null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 세금계산서 상세 */}
|
||||
{showDetail && selectedInvoice && (
|
||||
<TaxInvoiceDetail
|
||||
open={showDetail}
|
||||
onClose={() => setShowDetail(false)}
|
||||
invoiceId={selectedInvoice.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<Dialog
|
||||
open={confirmDialog.open}
|
||||
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmDialog.type === "delete" && "세금계산서 삭제"}
|
||||
{confirmDialog.type === "issue" && "세금계산서 발행"}
|
||||
{confirmDialog.type === "cancel" && "세금계산서 취소"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmDialog.type === "delete" &&
|
||||
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
|
||||
{confirmDialog.type === "issue" &&
|
||||
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
|
||||
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
|
||||
onClick={handleConfirmAction}
|
||||
>
|
||||
{confirmDialog.type === "delete" && "삭제"}
|
||||
{confirmDialog.type === "issue" && "발행"}
|
||||
{confirmDialog.type === "cancel" && "취소 처리"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { TaxInvoiceList } from "./TaxInvoiceList";
|
||||
export { TaxInvoiceForm } from "./TaxInvoiceForm";
|
||||
export { TaxInvoiceDetail } from "./TaxInvoiceDetail";
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -54,12 +54,12 @@ const DialogContent = React.forwardRef<
|
|||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left shrink-0", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,601 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 🆕 Context를 사용하여 open 상태 공유
|
||||
const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false });
|
||||
|
||||
// 🆕 ResizableDialog를 래핑하여 Context 제공
|
||||
const ResizableDialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
||||
children,
|
||||
open = false,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ResizableDialogContext.Provider value={{ open }}>
|
||||
<DialogPrimitive.Root open={open} {...props}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</ResizableDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ResizableDialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const ResizableDialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const ResizableDialogClose = DialogPrimitive.Close;
|
||||
|
||||
const ResizableDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ResizableDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
modalId?: string; // localStorage 저장용 고유 ID
|
||||
userId?: string; // 사용자별 저장용
|
||||
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
||||
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
|
||||
}
|
||||
|
||||
const ResizableDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
ResizableDialogContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
minWidth = 400,
|
||||
minHeight = 300,
|
||||
maxWidth = 1600,
|
||||
maxHeight = 1200,
|
||||
defaultWidth = 600,
|
||||
defaultHeight = 500,
|
||||
modalId,
|
||||
userId = "guest",
|
||||
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
||||
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
|
||||
style: userStyle,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 고정된 ID 생성 (한번 생성되면 컴포넌트 생명주기 동안 유지)
|
||||
const stableIdRef = React.useRef<string | null>(null);
|
||||
|
||||
if (!stableIdRef.current) {
|
||||
if (modalId) {
|
||||
stableIdRef.current = modalId;
|
||||
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
|
||||
} else {
|
||||
// className 기반 ID 생성
|
||||
if (className) {
|
||||
const hash = className.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
}, 0);
|
||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
|
||||
} else if (userStyle) {
|
||||
// userStyle 기반 ID 생성
|
||||
const styleStr = JSON.stringify(userStyle);
|
||||
const hash = styleStr.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
}, 0);
|
||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
|
||||
} else {
|
||||
// 기본 ID
|
||||
stableIdRef.current = 'modal-default';
|
||||
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveModalId = stableIdRef.current;
|
||||
|
||||
// 실제 렌더링된 크기를 감지하여 초기 크기로 사용
|
||||
const getInitialSize = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return { width: defaultWidth, height: defaultHeight };
|
||||
|
||||
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
|
||||
if (userStyle) {
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const finalSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
return finalSize;
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
|
||||
// if (contentRef.current) {
|
||||
// const rect = contentRef.current.getBoundingClientRect();
|
||||
// if (rect.width > 0 && rect.height > 0) {
|
||||
// return {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// 3순위: defaultWidth/defaultHeight 사용
|
||||
return { width: defaultWidth, height: defaultHeight };
|
||||
}, [defaultWidth, defaultHeight, minWidth, minHeight, maxWidth, maxHeight, userStyle]);
|
||||
|
||||
const [size, setSize] = React.useState(getInitialSize);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
// userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시)
|
||||
React.useEffect(() => {
|
||||
// 1. localStorage에서 사용자가 리사이징한 크기 확인
|
||||
let savedSize: { width: number; height: number; userResized: boolean } | null = null;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.userResized) {
|
||||
savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 우선순위: 사용자 리사이징 > userStyle > 기본값
|
||||
if (savedSize && savedSize.userResized) {
|
||||
// 사용자가 리사이징한 크기 우선
|
||||
setSize({ width: savedSize.width, height: savedSize.height });
|
||||
setUserResized(true);
|
||||
} else if (userStyle && userStyle.width && userStyle.height) {
|
||||
// 화면관리에서 설정한 크기
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const newSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]);
|
||||
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
|
||||
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
|
||||
|
||||
// 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open)
|
||||
const context = React.useContext(ResizableDialogContext);
|
||||
const actualOpen = externalOpen !== undefined ? externalOpen : context.open;
|
||||
|
||||
// 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋
|
||||
const [wasOpen, setWasOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
|
||||
|
||||
if (actualOpen && !wasOpen) {
|
||||
// 모달이 방금 열림
|
||||
// console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
|
||||
setIsInitialized(false);
|
||||
setWasOpen(true);
|
||||
} else if (!actualOpen && wasOpen) {
|
||||
// 모달이 방금 닫힘
|
||||
// console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
|
||||
setWasOpen(false);
|
||||
}
|
||||
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
|
||||
|
||||
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
|
||||
React.useEffect(() => {
|
||||
if (effectiveModalId !== lastModalId) {
|
||||
// console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
|
||||
setIsInitialized(false);
|
||||
setUserResized(false); // 사용자 리사이징 플래그도 리셋
|
||||
setLastModalId(effectiveModalId);
|
||||
}
|
||||
}, [effectiveModalId, lastModalId, isInitialized]);
|
||||
|
||||
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
|
||||
// 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경
|
||||
// React.useEffect(() => {
|
||||
// // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
//
|
||||
// if (!isInitialized) {
|
||||
// // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// // 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
// let attempts = 0;
|
||||
// const maxAttempts = 10;
|
||||
//
|
||||
// const measureContent = () => {
|
||||
// attempts++;
|
||||
//
|
||||
// // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
// let contentWidth = defaultWidth;
|
||||
// let contentHeight = defaultHeight;
|
||||
//
|
||||
// // if (contentRef.current) {
|
||||
// // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
// // contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
// // contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
// //
|
||||
// // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
// // } else {
|
||||
// // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
// //
|
||||
// // // contentRef가 아직 없으면 재시도
|
||||
// // if (attempts < maxAttempts) {
|
||||
// // setTimeout(measureContent, 100);
|
||||
// // return;
|
||||
// // }
|
||||
// // }
|
||||
//
|
||||
// // 패딩 추가 (p-6 * 2 = 48px)
|
||||
// const paddingAndMargin = 48;
|
||||
// const initialSize = getInitialSize();
|
||||
//
|
||||
// // 내용 크기 기반 최소 크기 계산
|
||||
// const contentBasedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
// };
|
||||
//
|
||||
// // console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
//
|
||||
// // localStorage에서 저장된 크기 확인
|
||||
// let finalSize = contentBasedSize;
|
||||
//
|
||||
// if (effectiveModalId && typeof window !== 'undefined') {
|
||||
// try {
|
||||
// const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
// const saved = localStorage.getItem(storageKey);
|
||||
//
|
||||
// // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
//
|
||||
// if (saved) {
|
||||
// const parsed = JSON.parse(saved);
|
||||
//
|
||||
// // userResized 플래그 확인
|
||||
// if (parsed.userResized) {
|
||||
// const savedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
// };
|
||||
//
|
||||
// // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
//
|
||||
// // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// // (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
// finalSize = savedSize;
|
||||
// setUserResized(true);
|
||||
//
|
||||
// // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
// } else {
|
||||
// // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
// }
|
||||
// } else {
|
||||
// // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // console.error("❌ 모달 크기 복원 실패:", error);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// setSize(finalSize);
|
||||
// setIsInitialized(true);
|
||||
// };
|
||||
//
|
||||
// // 첫 시도는 300ms 후에 시작
|
||||
// setTimeout(measureContent, 300);
|
||||
// }
|
||||
// }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
|
||||
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startWidth = size.width;
|
||||
const startHeight = size.height;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
if (direction.includes("e")) {
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX));
|
||||
}
|
||||
if (direction.includes("w")) {
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth - deltaX));
|
||||
}
|
||||
if (direction.includes("s")) {
|
||||
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
}
|
||||
if (direction.includes("n")) {
|
||||
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight - deltaY));
|
||||
}
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
setResizeDirection("");
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// 사용자가 리사이징했음을 표시
|
||||
setUserResized(true);
|
||||
|
||||
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
|
||||
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
|
||||
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
|
||||
const modalElement = contentRef.current.parentElement;
|
||||
const actualWidth = modalElement?.offsetWidth || size.width;
|
||||
const actualHeight = modalElement?.offsetHeight || size.height;
|
||||
|
||||
const currentSize = {
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
userResized: true, // 사용자가 직접 리사이징했음을 표시
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(currentSize));
|
||||
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
|
||||
} catch (error) {
|
||||
// console.error("❌ 모달 크기 저장 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<ResizableDialogPortal>
|
||||
<ResizableDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
isResizing && "select-none",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...userStyle,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "95vh",
|
||||
minWidth: `${minWidth}px`,
|
||||
minHeight: `${minHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full relative"
|
||||
style={{ display: 'block', overflow: 'auto', pointerEvents: 'auto', zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
{/* 오른쪽 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("e")}
|
||||
/>
|
||||
{/* 아래 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("s")}
|
||||
/>
|
||||
{/* 오른쪽 아래 */}
|
||||
<div
|
||||
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("se")}
|
||||
/>
|
||||
{/* 왼쪽 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("w")}
|
||||
/>
|
||||
{/* 위 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("n")}
|
||||
/>
|
||||
{/* 왼쪽 아래 */}
|
||||
<div
|
||||
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("sw")}
|
||||
/>
|
||||
{/* 오른쪽 위 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("ne")}
|
||||
/>
|
||||
{/* 왼쪽 위 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("nw")}
|
||||
/>
|
||||
|
||||
{/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */}
|
||||
{userResized && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// localStorage에서 저장된 크기 삭제
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
console.log("🗑️ 저장된 모달 크기 삭제:", storageKey);
|
||||
}
|
||||
|
||||
// 화면관리 설정 크기로 복원
|
||||
const initialSize = getInitialSize();
|
||||
setSize(initialSize);
|
||||
setUserResized(false);
|
||||
console.log("🔄 기본 크기로 리셋:", initialSize);
|
||||
}}
|
||||
className="absolute right-12 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
style={{ zIndex: 20 }}
|
||||
title="기본 크기로 리셋"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M3 21v-5h5"/>
|
||||
</svg>
|
||||
<span className="sr-only">기본 크기로 리셋</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
style={{ zIndex: 20 }}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</ResizableDialogPortal>
|
||||
);
|
||||
}
|
||||
);
|
||||
ResizableDialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const ResizableDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ResizableDialogHeader.displayName = "ResizableDialogHeader";
|
||||
|
||||
const ResizableDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ResizableDialogFooter.displayName = "ResizableDialogFooter";
|
||||
|
||||
const ResizableDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const ResizableDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogDescription.displayName =
|
||||
DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
ResizableDialog,
|
||||
ResizableDialogPortal,
|
||||
ResizableDialogOverlay,
|
||||
ResizableDialogClose,
|
||||
ResizableDialogTrigger,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogFooter,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
};
|
||||
|
||||
|
|
@ -91,6 +91,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
fields.forEach((field) => {
|
||||
item[field.name] = "";
|
||||
});
|
||||
// 🆕 새 항목임을 표시하는 플래그 추가 (백엔드에서 새 레코드로 처리)
|
||||
item._isNewItem = true;
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +115,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = true;
|
||||
}
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +132,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
} else {
|
||||
setItems(value);
|
||||
// 🆕 기존 레코드 플래그 추가
|
||||
const valueWithFlag = value.map(item => ({
|
||||
...item,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
setItems(valueWithFlag);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
|
@ -428,6 +440,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||
const mapping = categoryMappings[field.name];
|
||||
if (mapping && value) {
|
||||
const valueStr = String(value);
|
||||
const categoryData = mapping[valueStr];
|
||||
if (categoryData) {
|
||||
// 색상이 있으면 배지로 표시
|
||||
if (categoryData.color && categoryData.color !== "none" && categoryData.color !== "#64748b") {
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: categoryData.color,
|
||||
borderColor: categoryData.color,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{categoryData.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// 색상이 없으면 텍스트로 표시
|
||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
|
|
@ -556,44 +593,40 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
|
||||
// 카테고리 매핑 로드 (카테고리 필드 + readonly 필드에 대해 자동 로드)
|
||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
if (categoryFields.length === 0) return;
|
||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 1. 카테고리 타입 필드 매핑 로드
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name; // 실제 컬럼명
|
||||
const categoryCode = field.categoryCode || columnName;
|
||||
const columnName = field.name;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
// config에서 targetTable 가져오기, 없으면 스킵
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) {
|
||||
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!tableName) continue;
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
// 테이블 리스트와 동일한 API 사용
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일)
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일)
|
||||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -608,6 +641,50 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||
// material, division 등 조인된 테이블의 카테고리 필드
|
||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
||||
|
||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||
// item_info 테이블에서 카테고리 매핑 로드
|
||||
const joinedTableName = 'item_info';
|
||||
|
||||
for (const field of fieldsToLoadFromJoinedTable) {
|
||||
const columnName = field.name;
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
// 카테고리가 없는 필드는 무시
|
||||
console.log(`ℹ️ [RepeaterInput] 조인 테이블 카테고리 없음 (${columnName})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ interface SplitPanelContextValue {
|
|||
|
||||
// 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용)
|
||||
getLinkedFilterValues: () => Record<string, any>;
|
||||
|
||||
// 🆕 자동 데이터 전달 비활성화 여부 (버튼 클릭 시에만 데이터 전달)
|
||||
disableAutoDataTransfer: boolean;
|
||||
}
|
||||
|
||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||
|
|
@ -98,6 +101,7 @@ interface SplitPanelProviderProps {
|
|||
rightScreenId: number | null;
|
||||
parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정
|
||||
linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정
|
||||
disableAutoDataTransfer?: boolean; // 🆕 자동 데이터 전달 비활성화 (버튼 클릭 시에만 전달)
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +114,7 @@ export function SplitPanelProvider({
|
|||
rightScreenId,
|
||||
parentDataMapping = [],
|
||||
linkedFilters = [],
|
||||
disableAutoDataTransfer = false,
|
||||
children,
|
||||
}: SplitPanelProviderProps) {
|
||||
// 좌측/우측 화면의 데이터 수신자 맵
|
||||
|
|
@ -372,6 +377,8 @@ export function SplitPanelProvider({
|
|||
// 🆕 연결 필터 관련
|
||||
linkedFilters,
|
||||
getLinkedFilterValues,
|
||||
// 🆕 자동 데이터 전달 비활성화 여부
|
||||
disableAutoDataTransfer,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
|
|
@ -391,6 +398,7 @@ export function SplitPanelProvider({
|
|||
getMappedParentData,
|
||||
linkedFilters,
|
||||
getLinkedFilterValues,
|
||||
disableAutoDataTransfer,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -58,6 +58,18 @@ const TokenManager = {
|
|||
return null;
|
||||
},
|
||||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
}
|
||||
},
|
||||
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
|
@ -66,8 +78,147 @@ const TokenManager = {
|
|||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰이 곧 만료되는지 확인 (30분 이내)
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
const thirtyMinutes = 30 * 60 * 1000; // 30분
|
||||
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰 만료까지 남은 시간 (밀리초)
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 - Date.now();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 토큰 갱신 중복 방지 플래그
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
// 토큰 갱신 함수
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
// 이미 갱신 중이면 기존 Promise 반환
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 토큰 갱신 성공");
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[TokenManager] 토큰 갱신 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
// 자동 토큰 갱신 타이머
|
||||
let tokenRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// 자동 토큰 갱신 시작
|
||||
const startAutoRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (tokenRefreshTimer) {
|
||||
clearInterval(tokenRefreshTimer);
|
||||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
tokenRefreshTimer = setInterval(async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10분
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 활동 감지 및 토큰 갱신
|
||||
const setupActivityBasedRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let lastActivity = Date.now();
|
||||
const activityThreshold = 5 * 60 * 1000; // 5분
|
||||
|
||||
const handleActivity = (): void => {
|
||||
const now = Date.now();
|
||||
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인
|
||||
if (now - lastActivity > activityThreshold) {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
}
|
||||
lastActivity = now;
|
||||
};
|
||||
|
||||
// 사용자 활동 이벤트 감지
|
||||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
window.addEventListener(event, () => {
|
||||
if (!throttleTimer) {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
};
|
||||
|
||||
// 클라이언트 사이드에서 자동 갱신 시작
|
||||
if (typeof window !== "undefined") {
|
||||
startAutoRefresh();
|
||||
setupActivityBasedRefresh();
|
||||
}
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
|
@ -138,9 +289,15 @@ apiClient.interceptors.request.use(
|
|||
// 응답 인터셉터
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 백엔드에서 보내주는 새로운 토큰 처리
|
||||
const newToken = response.headers["x-new-token"];
|
||||
if (newToken) {
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
|
||||
|
|
@ -153,7 +310,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("⚠️ 데이터 중복:", {
|
||||
console.warn("데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
|
|
@ -161,7 +318,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("❌ API 응답 오류:", {
|
||||
console.error("API 응답 오류:", {
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: url,
|
||||
|
|
@ -170,24 +327,40 @@ apiClient.interceptors.response.use(
|
|||
headers: error.config?.headers,
|
||||
});
|
||||
|
||||
// 401 에러 시 상세 정보 출력
|
||||
if (status === 401) {
|
||||
console.error("🚨 401 Unauthorized 오류 상세 정보:", {
|
||||
// 401 에러 처리
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
|
||||
console.warn("[Auth] 401 오류 발생:", {
|
||||
url: url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
requestData: error.config?.data,
|
||||
responseData: error.response?.data,
|
||||
errorCode: errorCode,
|
||||
token: TokenManager.getToken() ? "존재" : "없음",
|
||||
});
|
||||
}
|
||||
|
||||
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
|
||||
console.log("[Auth] 토큰 만료, 갱신 시도...");
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken && originalRequest) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[Auth] 토큰 갱신 실패:", refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,29 @@ export async function reorderCategoryValues(orderedValueIds: number[]) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* @param valueCodes - 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||
* @returns { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
export async function getCategoryLabelsByCodes(valueCodes: string[]) {
|
||||
try {
|
||||
if (!valueCodes || valueCodes.length === 0) {
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: Record<string, string>;
|
||||
}>("/table-categories/labels-by-codes", { valueCodes });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
return { success: false, error: error.message, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* 세금계산서 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 비용 유형
|
||||
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||
|
||||
// 비용 유형 라벨
|
||||
export const costTypeLabels: Record<CostType, string> = {
|
||||
purchase: "구매",
|
||||
installation: "설치",
|
||||
repair: "수리",
|
||||
maintenance: "유지보수",
|
||||
disposal: "폐기",
|
||||
other: "기타",
|
||||
};
|
||||
|
||||
// 세금계산서 타입
|
||||
export interface TaxInvoice {
|
||||
id: string;
|
||||
company_code: string;
|
||||
invoice_number: string;
|
||||
invoice_type: "sales" | "purchase";
|
||||
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||
supplier_business_no: string;
|
||||
supplier_name: string;
|
||||
supplier_ceo_name: string;
|
||||
supplier_address: string;
|
||||
supplier_business_type: string;
|
||||
supplier_business_item: string;
|
||||
buyer_business_no: string;
|
||||
buyer_name: string;
|
||||
buyer_ceo_name: string;
|
||||
buyer_address: string;
|
||||
buyer_email: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
issue_date: string | null;
|
||||
remarks: string;
|
||||
order_id: string | null;
|
||||
customer_id: string | null;
|
||||
attachments: TaxInvoiceAttachment[] | null;
|
||||
cost_type: CostType | null; // 비용 유형
|
||||
created_date: string;
|
||||
updated_date: string;
|
||||
writer: string;
|
||||
}
|
||||
|
||||
// 첨부파일 타입
|
||||
export interface TaxInvoiceAttachment {
|
||||
id: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
uploaded_at: string;
|
||||
uploaded_by: string;
|
||||
}
|
||||
|
||||
// 세금계산서 품목 타입
|
||||
export interface TaxInvoiceItem {
|
||||
id: string;
|
||||
tax_invoice_id: string;
|
||||
company_code: string;
|
||||
item_seq: number;
|
||||
item_date: string;
|
||||
item_name: string;
|
||||
item_spec: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
// 생성 DTO
|
||||
export interface CreateTaxInvoiceDto {
|
||||
invoice_type: "sales" | "purchase";
|
||||
supplier_business_no?: string;
|
||||
supplier_name?: string;
|
||||
supplier_ceo_name?: string;
|
||||
supplier_address?: string;
|
||||
supplier_business_type?: string;
|
||||
supplier_business_item?: string;
|
||||
buyer_business_no?: string;
|
||||
buyer_name?: string;
|
||||
buyer_ceo_name?: string;
|
||||
buyer_address?: string;
|
||||
buyer_email?: string;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
invoice_date: string;
|
||||
remarks?: string;
|
||||
order_id?: string;
|
||||
customer_id?: string;
|
||||
items?: CreateTaxInvoiceItemDto[];
|
||||
attachments?: TaxInvoiceAttachment[];
|
||||
cost_type?: CostType; // 비용 유형
|
||||
}
|
||||
|
||||
// 품목 생성 DTO
|
||||
export interface CreateTaxInvoiceItemDto {
|
||||
item_date?: string;
|
||||
item_name: string;
|
||||
item_spec?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
// 목록 조회 파라미터
|
||||
export interface TaxInvoiceListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
invoice_type?: "sales" | "purchase";
|
||||
invoice_status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
search?: string;
|
||||
buyer_name?: string;
|
||||
cost_type?: CostType; // 비용 유형 필터
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
export interface TaxInvoiceListResponse {
|
||||
success: boolean;
|
||||
data: TaxInvoice[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 상세 응답
|
||||
export interface TaxInvoiceDetailResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
invoice: TaxInvoice;
|
||||
items: TaxInvoiceItem[];
|
||||
};
|
||||
}
|
||||
|
||||
// 월별 통계 응답
|
||||
export interface TaxInvoiceMonthlyStatsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||
};
|
||||
period: { year: number; month: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
*/
|
||||
export async function getTaxInvoiceList(
|
||||
params?: TaxInvoiceListParams
|
||||
): Promise<TaxInvoiceListResponse> {
|
||||
const response = await apiClient.get("/tax-invoice", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
*/
|
||||
export async function getTaxInvoiceById(id: string): Promise<TaxInvoiceDetailResponse> {
|
||||
const response = await apiClient.get(`/tax-invoice/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
*/
|
||||
export async function createTaxInvoice(
|
||||
data: CreateTaxInvoiceDto
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post("/tax-invoice", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
*/
|
||||
export async function updateTaxInvoice(
|
||||
id: string,
|
||||
data: Partial<CreateTaxInvoiceDto>
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.put(`/tax-invoice/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
*/
|
||||
export async function deleteTaxInvoice(
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.delete(`/tax-invoice/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
*/
|
||||
export async function issueTaxInvoice(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post(`/tax-invoice/${id}/issue`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
*/
|
||||
export async function cancelTaxInvoice(
|
||||
id: string,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; data: TaxInvoice; message: string }> {
|
||||
const response = await apiClient.post(`/tax-invoice/${id}/cancel`, { reason });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
export async function getTaxInvoiceMonthlyStats(
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<TaxInvoiceMonthlyStatsResponse> {
|
||||
const params: Record<string, number> = {};
|
||||
if (year) params.year = year;
|
||||
if (month) params.month = month;
|
||||
const response = await apiClient.get("/tax-invoice/stats/monthly", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 비용 유형별 통계 응답
|
||||
export interface CostTypeStatsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
by_cost_type: Array<{
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
by_month: Array<{
|
||||
year_month: string;
|
||||
cost_type: CostType | null;
|
||||
count: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
summary: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
purchase_amount: number;
|
||||
installation_amount: number;
|
||||
repair_amount: number;
|
||||
maintenance_amount: number;
|
||||
disposal_amount: number;
|
||||
other_amount: number;
|
||||
};
|
||||
};
|
||||
period: { year?: number; month?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
*/
|
||||
export async function getCostTypeStats(
|
||||
year?: number,
|
||||
month?: number
|
||||
): Promise<CostTypeStatsResponse> {
|
||||
const params: Record<string, number> = {};
|
||||
if (year) params.year = year;
|
||||
if (month) params.month = month;
|
||||
const response = await apiClient.get("/tax-invoice/stats/cost-type", { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -345,7 +345,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
}
|
||||
|
||||
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||
if (splitPanelContext && splitPanelPosition === "left") {
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (checked) {
|
||||
splitPanelContext.setSelectedLeftData(data);
|
||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
||||
|
|
@ -968,7 +969,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
{/* 상세보기 모달 */}
|
||||
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">📋</span>
|
||||
|
|
@ -1041,7 +1042,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
{/* 편집 모달 */}
|
||||
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">✏️</span>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function EntitySearchModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen,
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
@ -506,7 +506,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen,
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
|
||||
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
|
|||
|
||||
// 🆕 섹션 그룹화 레이아웃 컴포넌트
|
||||
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
|
||||
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
|
||||
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
|
||||
|
||||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
|
@ -77,6 +77,12 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
|||
// 🆕 범용 폼 모달 컴포넌트
|
||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||
|
||||
// 🆕 렉 구조 설정 컴포넌트
|
||||
import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성
|
||||
|
||||
// 🆕 세금계산서 관리 컴포넌트
|
||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue