feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,7 @@ const form = useForm<FormData>({
|
|||||||
- 페이지네이션 조회 → `executePaginatedAction()` 사용
|
- 페이지네이션 조회 → `executePaginatedAction()` 사용
|
||||||
- 단건/목록 조회 → `executeServerAction()` 유지
|
- 단건/목록 조회 → `executeServerAction()` 유지
|
||||||
- `toPaginationMeta()` 직접 사용도 허용
|
- `toPaginationMeta()` 직접 사용도 허용
|
||||||
|
- **`'use server'` 파일에서 타입 re-export 금지** — `export type { X } from '...'` 사용 불가 (Next.js Turbopack 제한: async 함수만 export 허용). 인라인 `export interface` / `export type X = ...`는 허용. 컴포넌트에서 타입이 필요하면 원본 파일에서 직접 import할 것
|
||||||
|
|
||||||
### 현황:
|
### 현황:
|
||||||
- **전체 43개 actions.ts 마이그레이션 완료** (2026-02-12)
|
- **전체 43개 actions.ts 마이그레이션 완료** (2026-02-12)
|
||||||
|
|||||||
@@ -188,6 +188,20 @@ export const remove = service.remove;
|
|||||||
- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조)
|
- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조)
|
||||||
- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가)
|
- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가)
|
||||||
- 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립
|
- 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립
|
||||||
|
- **화면 검수 완료** (4개 페이지): Bills, StockStatus, Quotes, Shipments — 전체 PASS
|
||||||
|
- **버그 발견/수정**: `quotes/actions.ts`에서 `export type { PaginationMeta }` re-export가 Turbopack 런타임 에러 유발 (`tsc`로 미감지) → re-export 제거, 컴포넌트에서 `@/lib/api/types` 직접 import로 변경
|
||||||
|
|
||||||
|
### `'use server'` 파일 타입 export 제한 (2026-02-12)
|
||||||
|
|
||||||
|
**발견 배경**: `executePaginatedAction` 마이그레이션 화면 검수 중 견적관리 페이지 빌드 에러
|
||||||
|
|
||||||
|
**제한 사항**:
|
||||||
|
- `'use server'` 파일에서는 **async 함수만 export 가능** (Next.js Turbopack 제한)
|
||||||
|
- `export type { X } from '...'` (re-export) → **런타임 에러 발생**
|
||||||
|
- `export interface X { ... }` / `export type X = ...` (인라인 정의) → **문제 없음** (컴파일 시 제거)
|
||||||
|
- `tsc --noEmit`으로는 감지 불가 — Next.js 전용 규칙이므로 실제 페이지 접속(Turbopack)에서만 발생
|
||||||
|
|
||||||
|
**현재 상태**: 전체 81개 `'use server'` 파일 점검 완료, re-export 패턴 0건 (수정된 1건 포함)
|
||||||
|
|
||||||
**buildApiUrl 마이그레이션 전략**:
|
**buildApiUrl 마이그레이션 전략**:
|
||||||
- Wave A: 1건짜리 단순 파일 20개
|
- Wave A: 1건짜리 단순 파일 20개
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관
|
|||||||
| **게시판관리** | `/ko/board/board-management` | 🆕 NEW |
|
| **게시판관리** | `/ko/board/board-management` | 🆕 NEW |
|
||||||
| **팝업관리** | `/ko/settings/popup-management` | 🆕 NEW |
|
| **팝업관리** | `/ko/settings/popup-management` | 🆕 NEW |
|
||||||
| **알림설정** | `/ko/settings/notification-settings` | 🆕 NEW |
|
| **알림설정** | `/ko/settings/notification-settings` | 🆕 NEW |
|
||||||
|
| **바로빌연동관리** | `/ko/settings/barobill-integration` | 🆕 NEW |
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:3000/ko/settings/leave-policy
|
http://localhost:3000/ko/settings/leave-policy
|
||||||
@@ -204,6 +205,7 @@ http://localhost:3000/ko/settings/notification-settings # 🆕 알림설정
|
|||||||
http://localhost:3000/ko/hr/card-management # 🆕 카드관리
|
http://localhost:3000/ko/hr/card-management # 🆕 카드관리
|
||||||
http://localhost:3000/ko/board/board-management # 🆕 게시판관리
|
http://localhost:3000/ko/board/board-management # 🆕 게시판관리
|
||||||
http://localhost:3000/ko/settings/popup-management # 🆕 팝업관리
|
http://localhost:3000/ko/settings/popup-management # 🆕 팝업관리
|
||||||
|
http://localhost:3000/ko/settings/barobill-integration # 🆕 바로빌연동관리
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -241,6 +243,10 @@ http://localhost:3000/ko/approval/reference # ✅ 참조함
|
|||||||
| **입출금 계좌조회** | `/ko/accounting/bank-transactions` | ✅ |
|
| **입출금 계좌조회** | `/ko/accounting/bank-transactions` | ✅ |
|
||||||
| **카드 내역 조회** | `/ko/accounting/card-transactions` | 🆕 NEW |
|
| **카드 내역 조회** | `/ko/accounting/card-transactions` | 🆕 NEW |
|
||||||
| **악성채권 추심관리** | `/ko/accounting/bad-debt-collection` | 🆕 NEW |
|
| **악성채권 추심관리** | `/ko/accounting/bad-debt-collection` | 🆕 NEW |
|
||||||
|
| **세금계산서 발행** | `/ko/accounting/tax-invoice-issuance` | 🆕 NEW |
|
||||||
|
| **세금계산서 관리** | `/ko/accounting/tax-invoices` | 🆕 NEW |
|
||||||
|
| **상품권관리** | `/ko/accounting/gift-certificates` | 🆕 NEW |
|
||||||
|
| **일반전표입력** | `/ko/accounting/general-journal-entry` | 🆕 NEW |
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:3000/ko/accounting/vendors # 거래처관리
|
http://localhost:3000/ko/accounting/vendors # 거래처관리
|
||||||
@@ -256,6 +262,10 @@ http://localhost:3000/ko/accounting/receivables-status # 미수금 현황
|
|||||||
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
||||||
http://localhost:3000/ko/accounting/card-transactions # 카드 내역 조회
|
http://localhost:3000/ko/accounting/card-transactions # 카드 내역 조회
|
||||||
http://localhost:3000/ko/accounting/bad-debt-collection # 악성채권 추심관리
|
http://localhost:3000/ko/accounting/bad-debt-collection # 악성채권 추심관리
|
||||||
|
http://localhost:3000/ko/accounting/tax-invoice-issuance # 🆕 세금계산서 발행
|
||||||
|
http://localhost:3000/ko/accounting/tax-invoices # 🆕 세금계산서 관리
|
||||||
|
http://localhost:3000/ko/accounting/gift-certificates # 🆕 상품권관리
|
||||||
|
http://localhost:3000/ko/accounting/general-journal-entry # 🆕 일반전표입력
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -409,6 +419,7 @@ http://localhost:3000/ko/settings/notification-settings # 🆕 알림설정
|
|||||||
http://localhost:3000/ko/hr/card-management # 🆕 카드관리
|
http://localhost:3000/ko/hr/card-management # 🆕 카드관리
|
||||||
http://localhost:3000/ko/board/board-management # 🆕 게시판관리
|
http://localhost:3000/ko/board/board-management # 🆕 게시판관리
|
||||||
http://localhost:3000/ko/settings/popup-management # 🆕 팝업관리
|
http://localhost:3000/ko/settings/popup-management # 🆕 팝업관리
|
||||||
|
http://localhost:3000/ko/settings/barobill-integration # 🆕 바로빌연동관리
|
||||||
```
|
```
|
||||||
|
|
||||||
### Approval
|
### Approval
|
||||||
@@ -433,6 +444,9 @@ http://localhost:3000/ko/accounting/receivables-status # 미수금 현황
|
|||||||
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
||||||
http://localhost:3000/ko/accounting/card-transactions # 🆕 카드 내역 조회
|
http://localhost:3000/ko/accounting/card-transactions # 🆕 카드 내역 조회
|
||||||
http://localhost:3000/ko/accounting/bad-debt-collection # 악성채권 추심관리
|
http://localhost:3000/ko/accounting/bad-debt-collection # 악성채권 추심관리
|
||||||
|
http://localhost:3000/ko/accounting/tax-invoice-issuance # 🆕 세금계산서 발행
|
||||||
|
http://localhost:3000/ko/accounting/gift-certificates # 🆕 상품권관리
|
||||||
|
http://localhost:3000/ko/accounting/general-journal-entry # 🆕 일반전표입력
|
||||||
```
|
```
|
||||||
|
|
||||||
### Board
|
### Board
|
||||||
@@ -524,6 +538,7 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
|||||||
'/hr/card-management' // 카드관리 (🆕 NEW)
|
'/hr/card-management' // 카드관리 (🆕 NEW)
|
||||||
'/board/board-management' // 게시판관리 (🆕 NEW)
|
'/board/board-management' // 게시판관리 (🆕 NEW)
|
||||||
'/settings/popup-management' // 팝업관리 (🆕 NEW)
|
'/settings/popup-management' // 팝업관리 (🆕 NEW)
|
||||||
|
'/settings/barobill-integration' // 바로빌연동관리 (🆕 NEW)
|
||||||
|
|
||||||
// 계정/회사/구독 (사이드바 루트 레벨 별도 메뉴)
|
// 계정/회사/구독 (사이드바 루트 레벨 별도 메뉴)
|
||||||
'/settings/account-info' // 계정정보 (🆕 NEW)
|
'/settings/account-info' // 계정정보 (🆕 NEW)
|
||||||
@@ -550,6 +565,9 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
|||||||
'/accounting/bank-transactions' // 입출금 계좌조회
|
'/accounting/bank-transactions' // 입출금 계좌조회
|
||||||
'/accounting/card-transactions' // 카드 내역 조회
|
'/accounting/card-transactions' // 카드 내역 조회
|
||||||
'/accounting/bad-debt-collection' // 악성채권 추심관리
|
'/accounting/bad-debt-collection' // 악성채권 추심관리
|
||||||
|
'/accounting/tax-invoice-issuance' // 세금계산서 발행 (🆕 NEW)
|
||||||
|
'/accounting/gift-certificates' // 상품권관리 (🆕 NEW)
|
||||||
|
'/accounting/general-journal-entry' // 일반전표입력 (🆕 NEW)
|
||||||
|
|
||||||
// Board (게시판)
|
// Board (게시판)
|
||||||
'/board' // 게시판 목록
|
'/board' // 게시판 목록
|
||||||
@@ -569,4 +587,4 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
|||||||
## 작성일
|
## 작성일
|
||||||
|
|
||||||
- 최초 작성: 2025-12-06
|
- 최초 작성: 2025-12-06
|
||||||
- 최종 업데이트: 2026-02-03 (단가배포관리 추가)
|
- 최종 업데이트: 2026-02-13 (일반전표입력 추가)
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CardTransactionEditPage({ params }: PageProps) {
|
|
||||||
const { id } = use(params);
|
|
||||||
return <CardTransactionDetailClient transactionId={id} initialMode="edit" />;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CardTransactionDetailPage({ params }: PageProps) {
|
|
||||||
const { id } = use(params);
|
|
||||||
return <CardTransactionDetailClient transactionId={id} initialMode="view" />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
|
||||||
|
|
||||||
export default function CardTransactionNewPage() {
|
|
||||||
return <CardTransactionDetailClient initialMode="create" />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { CardTransactionInquiry } from '@/components/accounting/CardTransactionInquiry';
|
import { CardTransactionInquiry } from '@/components/accounting/CardTransactionInquiry';
|
||||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
|
||||||
|
|
||||||
export default function CardTransactionsPage() {
|
export default function CardTransactionsPage() {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const mode = searchParams.get('mode');
|
|
||||||
|
|
||||||
if (mode === 'new') {
|
|
||||||
return <CardTransactionDetailClient initialMode="create" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <CardTransactionInquiry />;
|
return <CardTransactionInquiry />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GeneralJournalEntry } from '@/components/accounting/GeneralJournalEntry';
|
||||||
|
|
||||||
|
export default function GeneralJournalEntryPage() {
|
||||||
|
return <GeneralJournalEntry />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { GiftCertificateManagement } from '@/components/accounting/GiftCertificateManagement';
|
||||||
|
import { GiftCertificateDetail } from '@/components/accounting/GiftCertificateManagement/GiftCertificateDetail';
|
||||||
|
import { getGiftCertificateById } from '@/components/accounting/GiftCertificateManagement/actions';
|
||||||
|
import type { GiftCertificateFormData } from '@/components/accounting/GiftCertificateManagement/types';
|
||||||
|
|
||||||
|
export default function GiftCertificatesPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
const [editData, setEditData] = useState<GiftCertificateFormData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && id) {
|
||||||
|
setIsLoading(true);
|
||||||
|
getGiftCertificateById(id)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setEditData(result.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
}, [mode, id]);
|
||||||
|
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <GiftCertificateDetail mode="new" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && id) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<GiftCertificateDetail
|
||||||
|
mode="edit"
|
||||||
|
id={id}
|
||||||
|
initialData={editData ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 - 컴포넌트가 자체 데이터 로딩 처리
|
||||||
|
return <GiftCertificateManagement />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { TaxInvoiceIssuancePage } from '@/components/accounting/TaxInvoiceIssuance';
|
||||||
|
import { TaxInvoiceDetail } from '@/components/accounting/TaxInvoiceIssuance/TaxInvoiceDetail';
|
||||||
|
import {
|
||||||
|
getTaxInvoices,
|
||||||
|
getSupplierSettings,
|
||||||
|
getTaxInvoiceById,
|
||||||
|
} from '@/components/accounting/TaxInvoiceIssuance/actions';
|
||||||
|
import type { TaxInvoiceRecord, TaxInvoiceFormData, SupplierSettings } from '@/components/accounting/TaxInvoiceIssuance/types';
|
||||||
|
import { createEmptyBusinessEntity } from '@/components/accounting/TaxInvoiceIssuance/types';
|
||||||
|
|
||||||
|
type TaxInvoiceDetailData = TaxInvoiceFormData & {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
status: TaxInvoiceRecord['status'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaxInvoiceIssuanceRoute() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
const [data, setData] = useState<TaxInvoiceRecord[]>([]);
|
||||||
|
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings>(createEmptyBusinessEntity());
|
||||||
|
const [editData, setEditData] = useState<TaxInvoiceDetailData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && id) {
|
||||||
|
getTaxInvoiceById(id)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setEditData(result.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([getTaxInvoices(), getSupplierSettings()])
|
||||||
|
.then(([invoicesResult, settingsResult]) => {
|
||||||
|
if (invoicesResult.success && invoicesResult.data) {
|
||||||
|
setData(invoicesResult.data);
|
||||||
|
}
|
||||||
|
if (settingsResult.success && settingsResult.data) {
|
||||||
|
setSupplierSettings(settingsResult.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [mode, id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && id) {
|
||||||
|
return (
|
||||||
|
<TaxInvoiceDetail
|
||||||
|
id={id}
|
||||||
|
initialData={editData ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaxInvoiceIssuancePage
|
||||||
|
initialData={data}
|
||||||
|
initialSupplierSettings={supplierSettings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TaxInvoiceManagement } from '@/components/accounting/TaxInvoiceManagement';
|
||||||
|
|
||||||
|
export default function TaxInvoicesPage() {
|
||||||
|
return <TaxInvoiceManagement />;
|
||||||
|
}
|
||||||
@@ -1,35 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
|
||||||
* 카드 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
import { getCard } from '@/components/hr/CardManagement/actions';
|
||||||
import {
|
import type { Card } from '@/components/hr/CardManagement/types';
|
||||||
getCard,
|
|
||||||
updateCard,
|
|
||||||
deleteCard,
|
|
||||||
} from '@/components/hr/CardManagement/actions';
|
|
||||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
|
||||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
|
||||||
|
|
||||||
export default function CardDetailPage() {
|
export default function CardDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const cardId = params.id as string;
|
const cardId = params.id as string;
|
||||||
|
|
||||||
const [card, setCard] = useState<Card | null>(null);
|
const [card, setCard] = useState<Card | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
|
||||||
const urlMode = searchParams.get('mode');
|
|
||||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadCard() {
|
async function loadCard() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -40,49 +24,28 @@ export default function CardDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError(result.error || '카드를 찾을 수 없습니다.');
|
setError(result.error || '카드를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Failed to load card:', err);
|
|
||||||
setError('카드 조회 중 오류가 발생했습니다.');
|
setError('카드 조회 중 오류가 발생했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCard();
|
loadCard();
|
||||||
}, [cardId]);
|
}, [cardId]);
|
||||||
|
|
||||||
// 수정 핸들러
|
|
||||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
|
||||||
const result = await updateCard(cardId, data as unknown as CardFormData);
|
|
||||||
return { success: result.success, error: result.error };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제 핸들러
|
|
||||||
const handleDelete = async () => {
|
|
||||||
const result = await deleteCard(cardId);
|
|
||||||
return { success: result.success, error: result.error };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 에러 상태
|
|
||||||
if (error && !isLoading) {
|
if (error && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegratedDetailTemplate
|
<CardDetail
|
||||||
config={cardConfig}
|
card={card ?? undefined}
|
||||||
mode={initialMode}
|
mode="view"
|
||||||
initialData={(card as unknown as Record<string, unknown>) || undefined}
|
|
||||||
itemId={cardId}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,14 @@
|
|||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { CardManagement } from '@/components/hr/CardManagement';
|
import { CardManagement } from '@/components/hr/CardManagement';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
|
||||||
import { createCard } from '@/components/hr/CardManagement/actions';
|
|
||||||
import type { CardFormData } from '@/components/hr/CardManagement/types';
|
|
||||||
|
|
||||||
export default function CardManagementPage() {
|
export default function CardManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mode = searchParams.get('mode');
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
// mode=new일 때 등록 화면 표시
|
|
||||||
if (mode === 'new') {
|
if (mode === 'new') {
|
||||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
return <CardDetail mode="create" />;
|
||||||
const result = await createCard(data as unknown as CardFormData);
|
|
||||||
return { success: result.success, error: result.error };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IntegratedDetailTemplate
|
|
||||||
config={cardConfig}
|
|
||||||
mode="create"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CardManagement />;
|
return <CardManagement />;
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계좌 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
* 계좌 상세/수정 페이지 - AccountDetailForm 적용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
|
||||||
import {
|
import {
|
||||||
getBankAccount,
|
getBankAccount,
|
||||||
updateBankAccount,
|
updateBankAccount,
|
||||||
deleteBankAccount,
|
deleteBankAccount,
|
||||||
} from '@/components/settings/AccountManagement/actions';
|
} from '@/components/settings/AccountManagement/actions';
|
||||||
import type { Account, AccountFormData } from '@/components/settings/AccountManagement/types';
|
import type { Account, AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
|
||||||
|
|
||||||
export default function AccountDetailPage() {
|
export default function AccountDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -25,11 +23,9 @@ export default function AccountDetailPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
|
||||||
const urlMode = searchParams.get('mode');
|
const urlMode = searchParams.get('mode');
|
||||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
const initialMode: 'view' | 'edit' = urlMode === 'edit' ? 'edit' : 'view';
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadAccount() {
|
async function loadAccount() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -40,50 +36,40 @@ export default function AccountDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError(result.error || '계좌를 찾을 수 없습니다.');
|
setError(result.error || '계좌를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Failed to load account:', err);
|
|
||||||
setError('계좌 조회 중 오류가 발생했습니다.');
|
setError('계좌 조회 중 오류가 발생했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAccount();
|
loadAccount();
|
||||||
}, [accountId]);
|
}, [accountId]);
|
||||||
|
|
||||||
// 수정 핸들러
|
const handleSubmit = async (data: AccountFormData) => {
|
||||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
const result = await updateBankAccount(accountId, data);
|
||||||
const result = await updateBankAccount(accountId, data as unknown as Partial<AccountFormData>);
|
|
||||||
return { success: result.success, error: result.error };
|
return { success: result.success, error: result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 핸들러
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const result = await deleteBankAccount(accountId);
|
const result = await deleteBankAccount(accountId);
|
||||||
return { success: result.success, error: result.error };
|
return { success: result.success, error: result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 에러 상태
|
|
||||||
if (error && !isLoading) {
|
if (error && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegratedDetailTemplate
|
<AccountDetailForm
|
||||||
config={accountConfig}
|
|
||||||
mode={initialMode}
|
mode={initialMode}
|
||||||
initialData={(account as unknown as Record<string, unknown>) || undefined}
|
initialData={account || undefined}
|
||||||
itemId={accountId}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
stickyButtons={true}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계좌 등록 페이지 - IntegratedDetailTemplate 적용
|
* 계좌 등록 페이지 - AccountDetailForm 적용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
|
||||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||||
|
|
||||||
export default function NewAccountPage() {
|
export default function NewAccountPage() {
|
||||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
const handleSubmit = async (data: AccountFormData) => {
|
||||||
const result = await createBankAccount(data as unknown as AccountFormData);
|
const result = await createBankAccount(data);
|
||||||
return { success: result.success, error: result.error };
|
return { success: result.success, error: result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <AccountDetailForm mode="create" onSubmit={handleSubmit} />;
|
||||||
<IntegratedDetailTemplate
|
|
||||||
config={accountConfig}
|
|
||||||
mode="create"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
stickyButtons={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { AccountManagement } from '@/components/settings/AccountManagement';
|
import { AccountManagement } from '@/components/settings/AccountManagement';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
|
||||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||||
|
|
||||||
@@ -12,18 +11,12 @@ export default function AccountsPage() {
|
|||||||
const mode = searchParams.get('mode');
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
if (mode === 'new') {
|
if (mode === 'new') {
|
||||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
const handleSubmit = async (data: AccountFormData) => {
|
||||||
const result = await createBankAccount(data as unknown as AccountFormData);
|
const result = await createBankAccount(data);
|
||||||
return { success: result.success, error: result.error };
|
return { success: result.success, error: result.error };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <AccountDetailForm mode="create" onSubmit={handleSubmit} />;
|
||||||
<IntegratedDetailTemplate
|
|
||||||
config={accountConfig}
|
|
||||||
mode="create"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AccountManagement />;
|
return <AccountManagement />;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BarobillIntegration } from '@/components/settings/BarobillIntegration';
|
||||||
|
|
||||||
|
export default function BarobillIntegrationPage() {
|
||||||
|
return <BarobillIntegration />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { TimePicker } from '@/components/ui/time-picker';
|
||||||
|
import type { BankTransaction, TransactionKind, TransactionFormData } from './types';
|
||||||
|
import {
|
||||||
|
createManualTransaction,
|
||||||
|
updateTransaction,
|
||||||
|
deleteTransaction,
|
||||||
|
restoreTransaction,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
// ===== Props =====
|
||||||
|
interface TransactionFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
transaction?: BankTransaction | null;
|
||||||
|
accountOptions: { id: number; label: string }[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 시간 포맷 변환 (API: HHMMSS ↔ TimePicker: HH:mm:ss) =====
|
||||||
|
function apiTimeToPickerFormat(hhmmss: string): string {
|
||||||
|
if (!hhmmss) return '';
|
||||||
|
const clean = hhmmss.replace(/[^0-9]/g, '');
|
||||||
|
if (clean.length < 4) return '';
|
||||||
|
const h = clean.slice(0, 2);
|
||||||
|
const m = clean.slice(2, 4);
|
||||||
|
const s = clean.slice(4, 6) || '00';
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerTimeToApiFormat(hhmm_ss: string): string {
|
||||||
|
if (!hhmm_ss) return '';
|
||||||
|
return hhmm_ss.replace(/:/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 초기 폼 데이터 =====
|
||||||
|
function getInitialFormData(transaction?: BankTransaction | null): TransactionFormData {
|
||||||
|
if (transaction) {
|
||||||
|
const amount = transaction.type === 'deposit'
|
||||||
|
? transaction.depositAmount
|
||||||
|
: transaction.withdrawalAmount;
|
||||||
|
return {
|
||||||
|
bankAccountId: transaction.bankAccountId ?? null,
|
||||||
|
transactionDate: transaction.transactionDate?.split(' ')[0] || format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
transactionTime: apiTimeToPickerFormat(transaction.transactionTime || ''),
|
||||||
|
type: transaction.type,
|
||||||
|
amount,
|
||||||
|
note: transaction.note || '',
|
||||||
|
depositorName: transaction.depositorName || '',
|
||||||
|
memo: transaction.memo || '',
|
||||||
|
branch: transaction.branch || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bankAccountId: null,
|
||||||
|
transactionDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
transactionTime: '',
|
||||||
|
type: 'deposit',
|
||||||
|
amount: 0,
|
||||||
|
note: '',
|
||||||
|
depositorName: '',
|
||||||
|
memo: '',
|
||||||
|
branch: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionFormModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mode,
|
||||||
|
transaction,
|
||||||
|
accountOptions,
|
||||||
|
onSuccess,
|
||||||
|
}: TransactionFormModalProps) {
|
||||||
|
const [formData, setFormData] = useState<TransactionFormData>(() =>
|
||||||
|
getInitialFormData(transaction)
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isRestoring, setIsRestoring] = useState(false);
|
||||||
|
|
||||||
|
// 원본 데이터 (수정 감지용)
|
||||||
|
const [originalFormData, setOriginalFormData] = useState<TransactionFormData>(() =>
|
||||||
|
getInitialFormData(transaction)
|
||||||
|
);
|
||||||
|
|
||||||
|
// transaction 변경 시 폼 데이터 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const data = getInitialFormData(transaction);
|
||||||
|
setFormData(data);
|
||||||
|
setOriginalFormData(data);
|
||||||
|
}
|
||||||
|
}, [open, transaction]);
|
||||||
|
|
||||||
|
// 수정된 필드 감지
|
||||||
|
const modifiedFields = useMemo(() => {
|
||||||
|
if (mode === 'create') return [];
|
||||||
|
const fields: string[] = [];
|
||||||
|
const keys: (keyof TransactionFormData)[] = [
|
||||||
|
'bankAccountId', 'transactionDate', 'transactionTime', 'type',
|
||||||
|
'amount', 'note', 'depositorName', 'memo', 'branch',
|
||||||
|
];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (String(formData[key]) !== String(originalFormData[key])) {
|
||||||
|
fields.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}, [formData, originalFormData, mode]);
|
||||||
|
|
||||||
|
const isFieldModified = useCallback(
|
||||||
|
(field: keyof TransactionFormData) => modifiedFields.includes(field),
|
||||||
|
[modifiedFields]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 필드 변경 핸들러
|
||||||
|
const handleChange = useCallback(
|
||||||
|
<K extends keyof TransactionFormData>(key: K, value: TransactionFormData[K]) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 잔액 자동계산 (표시용)
|
||||||
|
const calculatedBalance = useMemo(() => {
|
||||||
|
if (!transaction) return formData.amount;
|
||||||
|
const prevBalance = transaction.balance;
|
||||||
|
if (formData.type === 'deposit') {
|
||||||
|
return prevBalance + formData.amount;
|
||||||
|
}
|
||||||
|
return prevBalance - formData.amount;
|
||||||
|
}, [formData.amount, formData.type, transaction]);
|
||||||
|
|
||||||
|
// 저장/수정
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.bankAccountId) {
|
||||||
|
toast.error('계좌를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.transactionDate) {
|
||||||
|
toast.error('거래일을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.amount <= 0) {
|
||||||
|
toast.error('금액을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// TimePicker HH:mm:ss → API HHMMSS 변환
|
||||||
|
const apiFormData = {
|
||||||
|
...formData,
|
||||||
|
transactionTime: pickerTimeToApiFormat(formData.transactionTime),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
const result = await createManualTransaction(apiFormData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('수기 입력이 완료되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '수기 입력에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} else if (transaction) {
|
||||||
|
const result = await updateTransaction(transaction.sourceId, apiFormData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('수정이 완료되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('처리 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [formData, mode, transaction, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!transaction) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteTransaction(transaction.sourceId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('삭제가 완료되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('삭제 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [transaction, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
// 원본으로 복원 (③)
|
||||||
|
const handleRestore = useCallback(async () => {
|
||||||
|
if (!transaction) return;
|
||||||
|
setIsRestoring(true);
|
||||||
|
try {
|
||||||
|
const result = await restoreTransaction(transaction.sourceId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('원본으로 복원되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '복원에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('복원 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsRestoring(false);
|
||||||
|
}
|
||||||
|
}, [transaction, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
const isProcessing = isSaving || isDeleting || isRestoring;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>입출금 수기 입력</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
{/* 계좌 * (①) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">
|
||||||
|
계좌 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
key={`account-${formData.bankAccountId}`}
|
||||||
|
value={formData.bankAccountId ? String(formData.bankAccountId) : ''}
|
||||||
|
onValueChange={(v) => handleChange('bankAccountId', parseInt(v, 10))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="계좌를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{accountOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 거래일 * / 거래시간 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">
|
||||||
|
거래일 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={formData.transactionDate}
|
||||||
|
onChange={(date) => handleChange('transactionDate', date)}
|
||||||
|
placeholder="날짜 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">거래시간</Label>
|
||||||
|
<TimePicker
|
||||||
|
value={formData.transactionTime || undefined}
|
||||||
|
onChange={(time) => handleChange('transactionTime', time)}
|
||||||
|
placeholder="시간 선택"
|
||||||
|
showSeconds
|
||||||
|
secondStep={1}
|
||||||
|
minuteStep={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 거래유형 * */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">
|
||||||
|
거래유형 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(v) => handleChange('type', v as TransactionKind)}
|
||||||
|
className="flex gap-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="deposit" id="type-deposit" />
|
||||||
|
<Label htmlFor="type-deposit" className="cursor-pointer">입금</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="withdrawal" id="type-withdrawal" />
|
||||||
|
<Label htmlFor="type-withdrawal" className="cursor-pointer">출금</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 금액 * / 잔액 (자동계산) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">
|
||||||
|
금액 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={formData.amount ? formData.amount.toLocaleString() : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
handleChange('amount', raw ? parseInt(raw, 10) : 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-gray-500">잔액 (자동계산)</Label>
|
||||||
|
<Input
|
||||||
|
value={calculatedBalance.toLocaleString()}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 적요 + 수정 스티커 (②) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="font-medium">적요</Label>
|
||||||
|
{mode === 'edit' && isFieldModified('note') && (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300 text-xs">
|
||||||
|
수정
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={formData.note}
|
||||||
|
onChange={(e) => handleChange('note', e.target.value)}
|
||||||
|
placeholder="내용"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상대계좌 예금주명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">상대계좌 예금주명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.depositorName}
|
||||||
|
onChange={(e) => handleChange('depositorName', e.target.value)}
|
||||||
|
placeholder="예금주명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메모 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">메모</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(e) => handleChange('memo', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 취급점 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium">취급점</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.branch}
|
||||||
|
onChange={(e) => handleChange('branch', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
{/* 좌측: 원본으로 복원 (③) - 수정 모드에서만 */}
|
||||||
|
<div>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRestore}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{isRestoring ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
원본으로 복원
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 삭제 + 수정/등록 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||||
|
{mode === 'create' ? '등록' : '수정'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,16 +4,18 @@
|
|||||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
import { buildApiUrl } from '@/lib/api/query-params';
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
import type { BankTransaction, TransactionKind } from './types';
|
import type { BankTransaction, TransactionKind, TransactionFormData, AccountCategoryFilter } from './types';
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
interface BankTransactionApiItem {
|
interface BankTransactionApiItem {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'deposit' | 'withdrawal';
|
type: 'deposit' | 'withdrawal';
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
|
transaction_time?: string | null;
|
||||||
bank_account_id: number;
|
bank_account_id: number;
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
account_name: string;
|
account_name: string;
|
||||||
|
account_number?: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
vendor_id: number | null;
|
vendor_id: number | null;
|
||||||
vendor_name: string | null;
|
vendor_name: string | null;
|
||||||
@@ -23,6 +25,10 @@ interface BankTransactionApiItem {
|
|||||||
balance: number | string;
|
balance: number | string;
|
||||||
transaction_type: string | null;
|
transaction_type: string | null;
|
||||||
source_id: string;
|
source_id: string;
|
||||||
|
memo?: string | null;
|
||||||
|
branch?: string | null;
|
||||||
|
is_manual?: boolean;
|
||||||
|
modified_fields?: string[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -30,6 +36,9 @@ interface BankTransactionApiItem {
|
|||||||
interface BankTransactionApiSummary {
|
interface BankTransactionApiSummary {
|
||||||
total_deposit: number;
|
total_deposit: number;
|
||||||
total_withdrawal: number;
|
total_withdrawal: number;
|
||||||
|
total_balance: number;
|
||||||
|
account_count: number;
|
||||||
|
unset_count: number;
|
||||||
deposit_unset_count: number;
|
deposit_unset_count: number;
|
||||||
withdrawal_unset_count: number;
|
withdrawal_unset_count: number;
|
||||||
}
|
}
|
||||||
@@ -40,7 +49,9 @@ function transformItem(item: BankTransactionApiItem): BankTransaction {
|
|||||||
id: `${item.type}-${item.id}`,
|
id: `${item.type}-${item.id}`,
|
||||||
bankName: item.bank_name,
|
bankName: item.bank_name,
|
||||||
accountName: item.account_name,
|
accountName: item.account_name,
|
||||||
|
accountNumber: item.account_number || undefined,
|
||||||
transactionDate: item.transaction_date,
|
transactionDate: item.transaction_date,
|
||||||
|
transactionTime: item.transaction_time || undefined,
|
||||||
type: item.type as TransactionKind,
|
type: item.type as TransactionKind,
|
||||||
note: item.note || undefined,
|
note: item.note || undefined,
|
||||||
vendorId: item.vendor_id ? String(item.vendor_id) : undefined,
|
vendorId: item.vendor_id ? String(item.vendor_id) : undefined,
|
||||||
@@ -51,6 +62,11 @@ function transformItem(item: BankTransactionApiItem): BankTransaction {
|
|||||||
balance: typeof item.balance === 'string' ? parseFloat(item.balance) : item.balance,
|
balance: typeof item.balance === 'string' ? parseFloat(item.balance) : item.balance,
|
||||||
transactionType: item.transaction_type || undefined,
|
transactionType: item.transaction_type || undefined,
|
||||||
sourceId: item.source_id,
|
sourceId: item.source_id,
|
||||||
|
bankAccountId: item.bank_account_id,
|
||||||
|
memo: item.memo || undefined,
|
||||||
|
branch: item.branch || undefined,
|
||||||
|
isManual: !!item.is_manual,
|
||||||
|
modifiedFields: item.modified_fields || undefined,
|
||||||
createdAt: item.created_at,
|
createdAt: item.created_at,
|
||||||
updatedAt: item.updated_at,
|
updatedAt: item.updated_at,
|
||||||
};
|
};
|
||||||
@@ -61,6 +77,7 @@ export async function getBankTransactionList(params?: {
|
|||||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||||
bankAccountId?: number; transactionType?: string; search?: string;
|
bankAccountId?: number; transactionType?: string; search?: string;
|
||||||
sortBy?: string; sortDir?: 'asc' | 'desc';
|
sortBy?: string; sortDir?: 'asc' | 'desc';
|
||||||
|
accountCategory?: AccountCategoryFilter; financialInstitution?: string;
|
||||||
}) {
|
}) {
|
||||||
return executePaginatedAction<BankTransactionApiItem, BankTransaction>({
|
return executePaginatedAction<BankTransactionApiItem, BankTransaction>({
|
||||||
url: buildApiUrl('/api/v1/bank-transactions', {
|
url: buildApiUrl('/api/v1/bank-transactions', {
|
||||||
@@ -73,6 +90,8 @@ export async function getBankTransactionList(params?: {
|
|||||||
search: params?.search,
|
search: params?.search,
|
||||||
sort_by: params?.sortBy,
|
sort_by: params?.sortBy,
|
||||||
sort_dir: params?.sortDir,
|
sort_dir: params?.sortDir,
|
||||||
|
account_category: params?.accountCategory !== 'all' ? params?.accountCategory : undefined,
|
||||||
|
financial_institution: params?.financialInstitution !== 'all' ? params?.financialInstitution : undefined,
|
||||||
}),
|
}),
|
||||||
transform: transformItem,
|
transform: transformItem,
|
||||||
errorMessage: '은행 거래 조회에 실패했습니다.',
|
errorMessage: '은행 거래 조회에 실패했습니다.',
|
||||||
@@ -80,9 +99,17 @@ export async function getBankTransactionList(params?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 입출금 요약 통계 =====
|
// ===== 입출금 요약 통계 =====
|
||||||
|
export interface BankTransactionSummaryData {
|
||||||
|
totalDeposit: number;
|
||||||
|
totalWithdrawal: number;
|
||||||
|
totalBalance: number;
|
||||||
|
accountCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBankTransactionSummary(params?: {
|
export async function getBankTransactionSummary(params?: {
|
||||||
startDate?: string; endDate?: string;
|
startDate?: string; endDate?: string;
|
||||||
}): Promise<ActionResult<{ totalDeposit: number; totalWithdrawal: number; depositUnsetCount: number; withdrawalUnsetCount: number }>> {
|
}): Promise<ActionResult<BankTransactionSummaryData>> {
|
||||||
return executeServerAction({
|
return executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/bank-transactions/summary', {
|
url: buildApiUrl('/api/v1/bank-transactions/summary', {
|
||||||
start_date: params?.startDate,
|
start_date: params?.startDate,
|
||||||
@@ -91,14 +118,15 @@ export async function getBankTransactionSummary(params?: {
|
|||||||
transform: (data: BankTransactionApiSummary) => ({
|
transform: (data: BankTransactionApiSummary) => ({
|
||||||
totalDeposit: data.total_deposit,
|
totalDeposit: data.total_deposit,
|
||||||
totalWithdrawal: data.total_withdrawal,
|
totalWithdrawal: data.total_withdrawal,
|
||||||
depositUnsetCount: data.deposit_unset_count,
|
totalBalance: data.total_balance ?? 0,
|
||||||
withdrawalUnsetCount: data.withdrawal_unset_count,
|
accountCount: data.account_count ?? 0,
|
||||||
|
unsetCount: data.unset_count ?? (data.deposit_unset_count + data.withdrawal_unset_count),
|
||||||
}),
|
}),
|
||||||
errorMessage: '요약 조회에 실패했습니다.',
|
errorMessage: '요약 조회에 실패했습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 계좌 목록 조회 (필터용) =====
|
// ===== 계좌 목록 조회 (필터 + 모달 Select용) =====
|
||||||
export async function getBankAccountOptions(): Promise<{
|
export async function getBankAccountOptions(): Promise<{
|
||||||
success: boolean; data: { id: number; label: string }[]; error?: string;
|
success: boolean; data: { id: number; label: string }[]; error?: string;
|
||||||
}> {
|
}> {
|
||||||
@@ -109,3 +137,118 @@ export async function getBankAccountOptions(): Promise<{
|
|||||||
});
|
});
|
||||||
return { success: result.success, data: result.data || [], error: result.error };
|
return { success: result.success, data: result.data || [], error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 금융기관 목록 조회 (⑤ 필터용) =====
|
||||||
|
export async function getFinancialInstitutions(): Promise<{
|
||||||
|
success: boolean; data: { value: string; label: string }[]; error?: string;
|
||||||
|
}> {
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/bank-transactions/financial-institutions'),
|
||||||
|
transform: (data: { code: string; name: string }[]) =>
|
||||||
|
data.map((item) => ({ value: item.code, label: item.name })),
|
||||||
|
errorMessage: '금융기관 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
return { success: result.success, data: result.data || [], error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수기 입력 (신규 생성) =====
|
||||||
|
export async function createManualTransaction(
|
||||||
|
formData: TransactionFormData
|
||||||
|
): Promise<ActionResult<BankTransaction>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/bank-transactions/manual'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
bank_account_id: formData.bankAccountId,
|
||||||
|
transaction_date: formData.transactionDate,
|
||||||
|
transaction_time: formData.transactionTime || undefined,
|
||||||
|
type: formData.type,
|
||||||
|
amount: formData.amount,
|
||||||
|
note: formData.note || undefined,
|
||||||
|
depositor_name: formData.depositorName || undefined,
|
||||||
|
memo: formData.memo || undefined,
|
||||||
|
branch: formData.branch || undefined,
|
||||||
|
},
|
||||||
|
transform: transformItem,
|
||||||
|
errorMessage: '수기 입력에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래 수정 =====
|
||||||
|
export async function updateTransaction(
|
||||||
|
id: string,
|
||||||
|
formData: Partial<TransactionFormData>
|
||||||
|
): Promise<ActionResult<BankTransaction>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/bank-transactions/${id}`),
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
bank_account_id: formData.bankAccountId,
|
||||||
|
transaction_date: formData.transactionDate,
|
||||||
|
transaction_time: formData.transactionTime || undefined,
|
||||||
|
type: formData.type,
|
||||||
|
amount: formData.amount,
|
||||||
|
note: formData.note ?? undefined,
|
||||||
|
depositor_name: formData.depositorName ?? undefined,
|
||||||
|
memo: formData.memo ?? undefined,
|
||||||
|
branch: formData.branch ?? undefined,
|
||||||
|
},
|
||||||
|
transform: transformItem,
|
||||||
|
errorMessage: '거래 수정에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래 삭제 =====
|
||||||
|
export async function deleteTransaction(id: string): Promise<ActionResult<null>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/bank-transactions/${id}`),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '거래 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 원본으로 복원 =====
|
||||||
|
export async function restoreTransaction(id: string): Promise<ActionResult<BankTransaction>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/bank-transactions/${id}/restore`),
|
||||||
|
method: 'POST',
|
||||||
|
transform: transformItem,
|
||||||
|
errorMessage: '원본 복원에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 변경사항 일괄 저장 =====
|
||||||
|
export async function batchSaveTransactions(
|
||||||
|
changes: { id: string; data: Partial<TransactionFormData> }[]
|
||||||
|
): Promise<ActionResult<null>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/bank-transactions/batch-save'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
transactions: changes.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
...c.data,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
errorMessage: '일괄 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 엑셀 다운로드 =====
|
||||||
|
export async function exportBankTransactionsExcel(params?: {
|
||||||
|
startDate?: string; endDate?: string;
|
||||||
|
accountCategory?: string; financialInstitution?: string;
|
||||||
|
}): Promise<ActionResult<{ downloadUrl: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/bank-transactions/export', {
|
||||||
|
start_date: params?.startDate,
|
||||||
|
end_date: params?.endDate,
|
||||||
|
account_category: params?.accountCategory,
|
||||||
|
financial_institution: params?.financialInstitution,
|
||||||
|
}),
|
||||||
|
transform: (data: { download_url: string }) => ({
|
||||||
|
downloadUrl: data.download_url,
|
||||||
|
}),
|
||||||
|
errorMessage: '엑셀 다운로드에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입출금 계좌조회 - UniversalListPage 마이그레이션
|
* 계좌 입출금 내역
|
||||||
*
|
*
|
||||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
* 기획서 기준:
|
||||||
* - 서버 사이드 필터링/페이지네이션
|
* - 통계 5개: 입금, 출금, 잔고, 계좌, 거래
|
||||||
* - dateRangeSelector (헤더 액션)
|
* - 테이블 11컬럼: 체크박스, No., 거래일시, 구분, 계좌정보, 적요/내용, 입금, 출금, 잔액, 취급점, 상대계좌예금주명
|
||||||
* - beforeTableContent: 새로고침 버튼
|
* - 필터: ④구분(은행계좌/대출계좌/증권계좌/보험계좌), ⑤금융기관
|
||||||
* - tableHeaderActions: 3개 Select 필터 (결제계좌, 입출금유형, 정렬)
|
* - 액션: ①저장, 엑셀 다운로드, ②입출금 수기 입력
|
||||||
* - tableFooter: 합계 행
|
* - 행 클릭 → 수기 입력/수정 모달
|
||||||
* - 수정 버튼 (입금/출금 상세 페이지 이동)
|
* - ③수정 영역 하이라이트, ⑥수정 스티커
|
||||||
|
* - 범례: 수기 계좌(🟠) / 연동 계좌(🔵)
|
||||||
|
* - 날짜 프리셋: 이번달, 지난달, D-2월~D-5월
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
import { Building2, Pencil, RefreshCw, Loader2 } from 'lucide-react';
|
import {
|
||||||
|
Building2, Save, Download, Plus, RefreshCw, Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -35,85 +39,82 @@ import {
|
|||||||
type RowClickHandlers,
|
type RowClickHandlers,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import type { BankTransaction, SortOption } from './types';
|
import type { BankTransaction, AccountCategoryFilter, SortOption } from './types';
|
||||||
import {
|
import {
|
||||||
TRANSACTION_KIND_LABELS,
|
TRANSACTION_KIND_LABELS,
|
||||||
DEPOSIT_TYPE_LABELS,
|
ACCOUNT_CATEGORY_OPTIONS,
|
||||||
WITHDRAWAL_TYPE_LABELS,
|
ACCOUNT_CATEGORY_LABELS,
|
||||||
SORT_OPTIONS,
|
|
||||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
getBankTransactionList,
|
getBankTransactionList,
|
||||||
getBankTransactionSummary,
|
getBankTransactionSummary,
|
||||||
getBankAccountOptions,
|
getBankAccountOptions,
|
||||||
|
getFinancialInstitutions,
|
||||||
|
batchSaveTransactions,
|
||||||
|
exportBankTransactionsExcel,
|
||||||
|
type BankTransactionSummaryData,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
import { TransactionFormModal } from './TransactionFormModal';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
|
|
||||||
// ===== 테이블 컬럼 정의 =====
|
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||||
const tableColumns = [
|
const tableColumns = [
|
||||||
{ key: 'bankName', label: '은행명' },
|
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
|
||||||
{ key: 'accountName', label: '계좌명' },
|
|
||||||
{ key: 'transactionDate', label: '거래일시' },
|
{ key: 'transactionDate', label: '거래일시' },
|
||||||
{ key: 'type', label: '구분', className: 'text-center' },
|
{ key: 'type', label: '구분', className: 'text-center' },
|
||||||
{ key: 'note', label: '적요' },
|
{ key: 'accountInfo', label: '계좌정보' },
|
||||||
{ key: 'vendorName', label: '거래처' },
|
{ key: 'note', label: '적요/내용' },
|
||||||
{ key: 'depositorName', label: '입금자/수취인' },
|
|
||||||
{ key: 'depositAmount', label: '입금', className: 'text-right' },
|
{ key: 'depositAmount', label: '입금', className: 'text-right' },
|
||||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right' },
|
{ key: 'withdrawalAmount', label: '출금', className: 'text-right' },
|
||||||
{ key: 'balance', label: '잔액', className: 'text-right' },
|
{ key: 'balance', label: '잔액', className: 'text-right' },
|
||||||
{ key: 'transactionType', label: '입출금 유형', className: 'text-center' },
|
{ key: 'branch', label: '취급점', className: 'text-center' },
|
||||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
{ key: 'depositorName', label: '상대계좌예금주명' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== 기본 Summary =====
|
||||||
interface BankTransactionInquiryProps {
|
const DEFAULT_SUMMARY: BankTransactionSummaryData = {
|
||||||
initialData?: BankTransaction[];
|
totalDeposit: 0,
|
||||||
initialSummary?: {
|
totalWithdrawal: 0,
|
||||||
totalDeposit: number;
|
totalBalance: 0,
|
||||||
totalWithdrawal: number;
|
accountCount: 0,
|
||||||
depositUnsetCount: number;
|
unsetCount: 0,
|
||||||
withdrawalUnsetCount: number;
|
};
|
||||||
};
|
|
||||||
initialPagination?: {
|
|
||||||
currentPage: number;
|
|
||||||
lastPage: number;
|
|
||||||
perPage: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BankTransactionInquiry({
|
export function BankTransactionInquiry() {
|
||||||
initialData = [],
|
// ===== 데이터 상태 =====
|
||||||
initialSummary,
|
const [data, setData] = useState<BankTransaction[]>([]);
|
||||||
initialPagination,
|
const [summary, setSummary] = useState<BankTransactionSummaryData>(DEFAULT_SUMMARY);
|
||||||
}: BankTransactionInquiryProps) {
|
const [pagination, setPagination] = useState({
|
||||||
const router = useRouter();
|
currentPage: 1, lastPage: 1, perPage: 20, total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
// 계좌/금융기관 옵션
|
||||||
const [data, setData] = useState<BankTransaction[]>(initialData);
|
const [accountOptions, setAccountOptions] = useState<{ id: number; label: string }[]>([]);
|
||||||
const [summary, setSummary] = useState(
|
const [financialInstitutionOptions, setFinancialInstitutionOptions] = useState<
|
||||||
initialSummary || { totalDeposit: 0, totalWithdrawal: 0, depositUnsetCount: 0, withdrawalUnsetCount: 0 }
|
{ value: string; label: string }[]
|
||||||
);
|
>([{ value: 'all', label: '전체' }]);
|
||||||
const [pagination, setPagination] = useState(
|
|
||||||
initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }
|
|
||||||
);
|
|
||||||
const [accountOptions, setAccountOptions] = useState<{ value: string; label: string }[]>([
|
|
||||||
{ value: 'all', label: '전체' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 필터 상태
|
// 필터 상태
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||||
const [accountFilter, setAccountFilter] = useState<string>('all');
|
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
|
||||||
const [transactionTypeFilter, setTransactionTypeFilter] = useState<string>('all');
|
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
|
||||||
const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(!initialData.length);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 날짜 범위 상태
|
// 날짜 범위
|
||||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||||
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const [selectedTransaction, setSelectedTransaction] = useState<BankTransaction | null>(null);
|
||||||
|
|
||||||
|
// 수정 추적 (로컬 변경사항)
|
||||||
|
const [localChanges, setLocalChanges] = useState<Map<string, Partial<BankTransaction>>>(new Map());
|
||||||
|
const [isBatchSaving, setIsBatchSaving] = useState(false);
|
||||||
|
|
||||||
// ===== 데이터 로드 =====
|
// ===== 데이터 로드 =====
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -126,35 +127,37 @@ export function BankTransactionInquiry({
|
|||||||
};
|
};
|
||||||
const sortParams = sortMapping[sortOption];
|
const sortParams = sortMapping[sortOption];
|
||||||
|
|
||||||
const [listResult, summaryResult, accountsResult] = await Promise.all([
|
const [listResult, summaryResult, accountsResult, fiResult] = await Promise.all([
|
||||||
getBankTransactionList({
|
getBankTransactionList({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
perPage: 20,
|
perPage: 20,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
bankAccountId: accountFilter !== 'all' ? parseInt(accountFilter, 10) : undefined,
|
|
||||||
transactionType: transactionTypeFilter !== 'all' ? transactionTypeFilter : undefined,
|
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
sortBy: sortParams.sortBy,
|
sortBy: sortParams.sortBy,
|
||||||
sortDir: sortParams.sortDir,
|
sortDir: sortParams.sortDir,
|
||||||
|
accountCategory: accountCategoryFilter,
|
||||||
|
financialInstitution: financialInstitutionFilter,
|
||||||
}),
|
}),
|
||||||
getBankTransactionSummary({ startDate, endDate }),
|
getBankTransactionSummary({ startDate, endDate }),
|
||||||
getBankAccountOptions(),
|
getBankAccountOptions(),
|
||||||
|
getFinancialInstitutions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (listResult.success) {
|
if (listResult.success) {
|
||||||
setData(listResult.data);
|
setData(listResult.data);
|
||||||
setPagination(listResult.pagination);
|
setPagination(listResult.pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summaryResult.success && summaryResult.data) {
|
if (summaryResult.success && summaryResult.data) {
|
||||||
setSummary(summaryResult.data);
|
setSummary(summaryResult.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountsResult.success) {
|
if (accountsResult.success) {
|
||||||
setAccountOptions([
|
setAccountOptions(accountsResult.data);
|
||||||
|
}
|
||||||
|
if (fiResult.success) {
|
||||||
|
setFinancialInstitutionOptions([
|
||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '전체' },
|
||||||
...accountsResult.data.map((acc) => ({ value: String(acc.id), label: acc.label })),
|
...fiResult.data,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -163,184 +166,236 @@ export function BankTransactionInquiry({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentPage, startDate, endDate, accountFilter, transactionTypeFilter, searchQuery, sortOption]);
|
}, [currentPage, startDate, endDate, searchQuery, sortOption, accountCategoryFilter, financialInstitutionFilter]);
|
||||||
|
|
||||||
// 데이터 로드 (필터 변경 시)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// ===== 핸들러 =====
|
// ===== 핸들러 =====
|
||||||
const handleEditClick = useCallback(
|
const handleRowClick = useCallback((item: BankTransaction) => {
|
||||||
(item: BankTransaction) => {
|
setSelectedTransaction(item);
|
||||||
if (item.type === 'deposit') {
|
setModalMode('edit');
|
||||||
router.push(`/ko/accounting/deposits/${item.sourceId}?mode=edit`);
|
setModalOpen(true);
|
||||||
} else {
|
}, []);
|
||||||
router.push(`/ko/accounting/withdrawals/${item.sourceId}?mode=edit`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleCreateClick = useCallback(() => {
|
||||||
|
setSelectedTransaction(null);
|
||||||
|
setModalMode('create');
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModalSuccess = useCallback(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// ===== 유형 라벨 가져오기 =====
|
// ① 저장 버튼 (변경사항 일괄 저장)
|
||||||
const getTransactionTypeLabel = useCallback((item: BankTransaction) => {
|
const handleBatchSave = useCallback(async () => {
|
||||||
if (!item.transactionType) return '미설정';
|
if (localChanges.size === 0) {
|
||||||
if (item.type === 'deposit') {
|
toast.info('변경된 내용이 없습니다.');
|
||||||
return DEPOSIT_TYPE_LABELS[item.transactionType as keyof typeof DEPOSIT_TYPE_LABELS] || item.transactionType;
|
return;
|
||||||
}
|
}
|
||||||
return WITHDRAWAL_TYPE_LABELS[item.transactionType as keyof typeof WITHDRAWAL_TYPE_LABELS] || item.transactionType;
|
setIsBatchSaving(true);
|
||||||
}, []);
|
try {
|
||||||
|
const changes = Array.from(localChanges.entries()).map(([id, data]) => ({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
bankAccountId: data.bankAccountId ?? undefined,
|
||||||
|
transactionDate: data.transactionDate,
|
||||||
|
type: data.type,
|
||||||
|
amount: data.depositAmount || data.withdrawalAmount,
|
||||||
|
note: data.note ?? '',
|
||||||
|
depositorName: data.depositorName ?? '',
|
||||||
|
memo: data.memo ?? '',
|
||||||
|
branch: data.branch ?? '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const result = await batchSaveTransactions(changes);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('저장이 완료되었습니다.');
|
||||||
|
setLocalChanges(new Map());
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsBatchSaving(false);
|
||||||
|
}
|
||||||
|
}, [localChanges, loadData]);
|
||||||
|
|
||||||
// ===== 테이블 합계 계산 =====
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await exportBankTransactionsExcel({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
accountCategory: accountCategoryFilter,
|
||||||
|
financialInstitution: financialInstitutionFilter,
|
||||||
|
});
|
||||||
|
if (result.success && result.data) {
|
||||||
|
window.open(result.data.downloadUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||||
|
|
||||||
|
// ===== 합계 계산 =====
|
||||||
const tableTotals = useMemo(() => {
|
const tableTotals = useMemo(() => {
|
||||||
const totalDeposit = data.reduce((sum, item) => sum + item.depositAmount, 0);
|
const totalDeposit = data.reduce((sum, item) => sum + item.depositAmount, 0);
|
||||||
const totalWithdrawal = data.reduce((sum, item) => sum + item.withdrawalAmount, 0);
|
const totalWithdrawal = data.reduce((sum, item) => sum + item.withdrawalAmount, 0);
|
||||||
return { totalDeposit, totalWithdrawal };
|
return { totalDeposit, totalWithdrawal };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// 행이 수정되었는지 확인
|
||||||
|
const isRowModified = useCallback(
|
||||||
|
(id: string) => localChanges.has(id) || false,
|
||||||
|
[localChanges]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 셀이 수정되었는지 확인 (서버에서 온 modifiedFields)
|
||||||
|
const isCellModified = useCallback(
|
||||||
|
(item: BankTransaction, field: string) => {
|
||||||
|
return item.modifiedFields?.includes(field) || false;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<BankTransaction> = useMemo(
|
const config: UniversalListConfig<BankTransaction> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
// 페이지 기본 정보
|
title: '계좌 입출금 내역',
|
||||||
title: '입출금 계좌조회',
|
description: '은행 계좌의 입출금 내역을 조회하고 관리합니다',
|
||||||
description: '은행 계좌 정보와 입출금 내역을 조회할 수 있습니다',
|
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
basePath: '/accounting/bank-transactions',
|
basePath: '/accounting/bank-transactions',
|
||||||
|
|
||||||
// ID 추출
|
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
|
|
||||||
// API 액션
|
|
||||||
actions: {
|
actions: {
|
||||||
getList: async () => {
|
getList: async () => ({
|
||||||
return {
|
success: true,
|
||||||
success: true,
|
data,
|
||||||
data: data,
|
totalCount: pagination.total,
|
||||||
totalCount: pagination.total,
|
}),
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테이블 컬럼
|
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
|
|
||||||
// 서버 사이드 필터링 (클라이언트 사이드 아님)
|
|
||||||
clientSideFiltering: false,
|
clientSideFiltering: false,
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
|
showCheckbox: true,
|
||||||
|
showRowNumber: true,
|
||||||
|
|
||||||
// 검색
|
// 검색
|
||||||
searchPlaceholder: '은행명, 계좌명, 거래처, 입금자/수취인 검색...',
|
searchPlaceholder: '계좌명, 적요, 예금주명 검색...',
|
||||||
onSearchChange: setSearchQuery,
|
onSearchChange: setSearchQuery,
|
||||||
searchFilter: (item: BankTransaction, search: string) => {
|
searchFilter: (item: BankTransaction, search: string) => {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
return (
|
return (
|
||||||
item.bankName?.toLowerCase().includes(s) ||
|
|
||||||
item.accountName?.toLowerCase().includes(s) ||
|
item.accountName?.toLowerCase().includes(s) ||
|
||||||
item.vendorName?.toLowerCase().includes(s) ||
|
item.note?.toLowerCase().includes(s) ||
|
||||||
item.depositorName?.toLowerCase().includes(s) ||
|
item.depositorName?.toLowerCase().includes(s) ||
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 필터 설정 (모바일용)
|
// 날짜 선택기 (이번달~D-5월 프리셋)
|
||||||
filterConfig: [
|
|
||||||
{
|
|
||||||
key: 'account',
|
|
||||||
label: '결제계좌',
|
|
||||||
type: 'single',
|
|
||||||
options: accountOptions.filter((o) => o.value !== 'all'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transactionType',
|
|
||||||
label: '입출금유형',
|
|
||||||
type: 'single',
|
|
||||||
options: TRANSACTION_TYPE_FILTER_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({
|
|
||||||
value: o.value,
|
|
||||||
label: o.label,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sortBy',
|
|
||||||
label: '정렬',
|
|
||||||
type: 'single',
|
|
||||||
options: SORT_OPTIONS.map((o) => ({
|
|
||||||
value: o.value,
|
|
||||||
label: o.label,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
initialFilters: {
|
|
||||||
account: 'all',
|
|
||||||
transactionType: 'all',
|
|
||||||
sortBy: 'latest',
|
|
||||||
},
|
|
||||||
filterTitle: '계좌 필터',
|
|
||||||
|
|
||||||
// 날짜 선택기 (헤더 액션)
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
|
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||||
|
presetLabels: {
|
||||||
|
thisMonth: '이번달',
|
||||||
|
lastMonth: '지난달',
|
||||||
|
twoMonthsAgo: 'D-2월',
|
||||||
|
threeMonthsAgo: 'D-3월',
|
||||||
|
fourMonthsAgo: 'D-4월',
|
||||||
|
fiveMonthsAgo: 'D-5월',
|
||||||
|
},
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
onEndDateChange: setEndDate,
|
onEndDateChange: setEndDate,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 헤더 액션: 새로고침 버튼
|
// 헤더 액션: ①저장 + 엑셀 다운로드 + ②수기 입력
|
||||||
headerActions: () => (
|
headerActions: () => (
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
<div className="flex items-center gap-2">
|
||||||
{isLoading ? (
|
<Button
|
||||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
size="sm"
|
||||||
) : (
|
onClick={handleBatchSave}
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
disabled={isBatchSaving || localChanges.size === 0}
|
||||||
)}
|
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||||
{isLoading ? '조회중...' : '새로고침'}
|
>
|
||||||
</Button>
|
{isBatchSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleCreateClick}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
입출금 수기 입력
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
|
|
||||||
// 테이블 헤더 액션 (3개 필터)
|
// 테이블 헤더 액션: 총 N건 + ④구분 + ⑤금융기관
|
||||||
tableHeaderActions: () => (
|
tableHeaderActions: () => (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{/* 결제계좌 필터 */}
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
<Select value={accountFilter} onValueChange={setAccountFilter}>
|
총 {pagination.total}건
|
||||||
<SelectTrigger className="w-[180px]">
|
</span>
|
||||||
<SelectValue placeholder="결제계좌" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="ghost"
|
||||||
<SelectContent>
|
size="icon"
|
||||||
{accountOptions.map((option) => (
|
className="h-8 w-8"
|
||||||
<SelectItem key={option.value} value={option.value}>
|
onClick={() => loadData()}
|
||||||
{option.label}
|
disabled={isLoading}
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
{isLoading ? (
|
||||||
</SelectContent>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</Select>
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* 입출금유형 필터 */}
|
{/* ④ 구분 */}
|
||||||
<Select value={transactionTypeFilter} onValueChange={setTransactionTypeFilter}>
|
<Select
|
||||||
<SelectTrigger className="w-[130px]">
|
value={accountCategoryFilter}
|
||||||
<SelectValue placeholder="입출금유형" />
|
onValueChange={(v) => setAccountCategoryFilter(v as AccountCategoryFilter)}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
|
||||||
{TRANSACTION_TYPE_FILTER_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder="정렬" />
|
<SelectValue placeholder="구분" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SORT_OPTIONS.map((option) => (
|
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{option.label}
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* ⑤ 금융기관 */}
|
||||||
|
<Select
|
||||||
|
value={financialInstitutionFilter}
|
||||||
|
onValueChange={setFinancialInstitutionFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="금융기관" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{financialInstitutionOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -348,31 +403,40 @@ export function BankTransactionInquiry({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
||||||
// 테이블 푸터 (합계 행)
|
// 합계 행 + 범례 (체크박스+No.+9데이터 = 12컬럼)
|
||||||
tableFooter: (
|
tableFooter: (
|
||||||
<TableRow className="bg-muted/50 font-medium">
|
<>
|
||||||
<TableCell className="text-center" />
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
<TableCell className="font-bold">합계</TableCell>
|
<TableCell />
|
||||||
<TableCell />
|
<TableCell colSpan={5} className="text-right font-bold">합계</TableCell>
|
||||||
<TableCell />
|
<TableCell className="text-right font-bold text-blue-600">
|
||||||
<TableCell />
|
{tableTotals.totalDeposit.toLocaleString()}
|
||||||
<TableCell />
|
</TableCell>
|
||||||
<TableCell />
|
<TableCell className="text-right font-bold text-red-600">
|
||||||
<TableCell />
|
{tableTotals.totalWithdrawal.toLocaleString()}
|
||||||
<TableCell />
|
</TableCell>
|
||||||
<TableCell className="text-right font-bold text-blue-600">
|
<TableCell />
|
||||||
{tableTotals.totalDeposit.toLocaleString()}
|
<TableCell />
|
||||||
</TableCell>
|
<TableCell />
|
||||||
<TableCell className="text-right font-bold text-red-600">
|
</TableRow>
|
||||||
{tableTotals.totalWithdrawal.toLocaleString()}
|
<TableRow>
|
||||||
</TableCell>
|
<TableCell colSpan={12} className="py-2">
|
||||||
<TableCell />
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<TableCell />
|
<span className="flex items-center gap-1.5">
|
||||||
<TableCell />
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-orange-400" />
|
||||||
</TableRow>
|
수기 계좌
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400" />
|
||||||
|
연동 계좌
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
|
|
||||||
// Stats 카드
|
// 통계 카드 5개
|
||||||
computeStats: (): StatCard[] => [
|
computeStats: (): StatCard[] => [
|
||||||
{
|
{
|
||||||
label: '입금',
|
label: '입금',
|
||||||
@@ -387,14 +451,20 @@ export function BankTransactionInquiry({
|
|||||||
iconColor: 'text-red-500',
|
iconColor: 'text-red-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '입금 유형 미설정',
|
label: '잔고',
|
||||||
value: `${summary.depositUnsetCount}건`,
|
value: `${summary.totalBalance.toLocaleString()}원`,
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
iconColor: 'text-green-500',
|
iconColor: 'text-green-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '출금 유형 미설정',
|
label: '계좌',
|
||||||
value: `${summary.withdrawalUnsetCount}건`,
|
value: `${summary.accountCount}개`,
|
||||||
|
icon: Building2,
|
||||||
|
iconColor: 'text-gray-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '거래',
|
||||||
|
value: `${pagination.total}건`,
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
iconColor: 'text-orange-500',
|
iconColor: 'text-orange-500',
|
||||||
},
|
},
|
||||||
@@ -403,76 +473,92 @@ export function BankTransactionInquiry({
|
|||||||
// 테이블 행 렌더링
|
// 테이블 행 렌더링
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
item: BankTransaction,
|
item: BankTransaction,
|
||||||
index: number,
|
_index: number,
|
||||||
globalIndex: number,
|
globalIndex: number,
|
||||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||||
) => {
|
) => {
|
||||||
const isTypeUnset = item.transactionType === 'unset';
|
const rowModified = isRowModified(item.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={`cursor-pointer hover:bg-muted/50 ${rowModified ? 'bg-green-50' : ''}`}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="text-center w-[40px]" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
<Checkbox
|
||||||
|
checked={handlers.isSelected}
|
||||||
|
onCheckedChange={() => handlers.onToggle()}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* 번호 */}
|
{/* No. */}
|
||||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||||
{/* 은행명 */}
|
|
||||||
<TableCell>{item.bankName}</TableCell>
|
|
||||||
{/* 계좌명 */}
|
|
||||||
<TableCell>{item.accountName}</TableCell>
|
|
||||||
{/* 거래일시 */}
|
{/* 거래일시 */}
|
||||||
<TableCell>{item.transactionDate}</TableCell>
|
<TableCell>
|
||||||
{/* 구분 */}
|
<span className={isCellModified(item, 'transaction_date') ? 'bg-green-100 px-1 rounded' : ''}>
|
||||||
<TableCell className="text-center">
|
{item.transactionDate}
|
||||||
<Badge
|
{item.transactionTime && (
|
||||||
variant="outline"
|
<span className="text-xs text-gray-400 ml-1">{item.transactionTime}</span>
|
||||||
className={
|
)}
|
||||||
item.type === 'deposit'
|
</span>
|
||||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
</TableCell>
|
||||||
: 'border-red-300 text-red-600 bg-red-50'
|
{/* 구분 (계좌 카테고리) */}
|
||||||
}
|
<TableCell className="text-center text-sm">
|
||||||
>
|
{item.accountCategory
|
||||||
{TRANSACTION_KIND_LABELS[item.type]}
|
? ACCOUNT_CATEGORY_LABELS[item.accountCategory]
|
||||||
</Badge>
|
: '은행계좌'}
|
||||||
|
</TableCell>
|
||||||
|
{/* 계좌정보 (수기: 🟠, 연동: 🔵) - 은행명 + 마스킹 계좌번호 */}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||||
|
item.isManual ? 'bg-orange-400' : 'bg-blue-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate">
|
||||||
|
{item.bankName || item.accountName}
|
||||||
|
{item.accountNumber && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
****{item.accountNumber.slice(-4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{/* 적요/내용 (수정 하이라이트) */}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={isCellModified(item, 'note') ? 'bg-green-100 px-1 rounded' : ''}>
|
||||||
|
{item.note || '-'}
|
||||||
|
</span>
|
||||||
|
{isCellModified(item, 'note') && (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300 text-[10px] px-1 py-0">
|
||||||
|
수정
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* 적요 */}
|
|
||||||
<TableCell className="text-gray-500">{item.note || '-'}</TableCell>
|
|
||||||
{/* 거래처 */}
|
|
||||||
<TableCell>{item.vendorName || '-'}</TableCell>
|
|
||||||
{/* 입금자/수취인 */}
|
|
||||||
<TableCell>{item.depositorName || '-'}</TableCell>
|
|
||||||
{/* 입금 */}
|
{/* 입금 */}
|
||||||
<TableCell className="text-right font-medium text-blue-600">
|
<TableCell className={`text-right font-medium text-blue-600 ${isCellModified(item, 'deposit_amount') ? 'bg-green-100 rounded' : ''}`}>
|
||||||
{item.depositAmount > 0 ? item.depositAmount.toLocaleString() : '-'}
|
{item.depositAmount > 0 ? item.depositAmount.toLocaleString() : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* 출금 */}
|
{/* 출금 */}
|
||||||
<TableCell className="text-right font-medium text-red-600">
|
<TableCell className={`text-right font-medium text-red-600 ${isCellModified(item, 'withdrawal_amount') ? 'bg-green-100 rounded' : ''}`}>
|
||||||
{item.withdrawalAmount > 0 ? item.withdrawalAmount.toLocaleString() : '-'}
|
{item.withdrawalAmount > 0 ? item.withdrawalAmount.toLocaleString() : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* 잔액 */}
|
{/* 잔액 */}
|
||||||
<TableCell className="text-right font-medium">{item.balance.toLocaleString()}</TableCell>
|
<TableCell className="text-right font-medium">
|
||||||
{/* 입출금 유형 */}
|
{item.balance.toLocaleString()}
|
||||||
<TableCell className="text-center">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={isTypeUnset ? 'border-red-300 text-red-500 bg-red-50' : ''}
|
|
||||||
>
|
|
||||||
{getTransactionTypeLabel(item)}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* 작업 */}
|
{/* 취급점 */}
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
{handlers.isSelected && (
|
{item.branch || '-'}
|
||||||
<Button
|
</TableCell>
|
||||||
variant="ghost"
|
{/* 상대계좌예금주명 */}
|
||||||
size="icon"
|
<TableCell className="text-sm">
|
||||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
{item.depositorName || '-'}
|
||||||
onClick={() => handleEditClick(item)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -481,75 +567,91 @@ export function BankTransactionInquiry({
|
|||||||
// 모바일 카드 렌더링
|
// 모바일 카드 렌더링
|
||||||
renderMobileCard: (
|
renderMobileCard: (
|
||||||
item: BankTransaction,
|
item: BankTransaction,
|
||||||
index: number,
|
_index: number,
|
||||||
globalIndex: number,
|
_globalIndex: number,
|
||||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||||
) => (
|
) => {
|
||||||
<MobileCard
|
return (
|
||||||
key={item.id}
|
<MobileCard
|
||||||
title={`${item.bankName} - ${item.accountName}`}
|
key={item.id}
|
||||||
subtitle={item.transactionDate}
|
title={`${item.bankName || item.accountName}${item.accountNumber ? ` ****${item.accountNumber.slice(-4)}` : ''}`}
|
||||||
badge={TRANSACTION_KIND_LABELS[item.type]}
|
subtitle={item.transactionDate}
|
||||||
badgeVariant="outline"
|
badge={TRANSACTION_KIND_LABELS[item.type]}
|
||||||
badgeClassName={
|
badgeVariant="outline"
|
||||||
item.type === 'deposit'
|
badgeClassName={
|
||||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
item.type === 'deposit'
|
||||||
: 'border-red-300 text-red-600 bg-red-50'
|
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||||
}
|
: 'border-red-300 text-red-600 bg-red-50'
|
||||||
isSelected={handlers.isSelected}
|
}
|
||||||
onToggle={handlers.onToggle}
|
isSelected={handlers.isSelected}
|
||||||
details={[
|
onToggle={handlers.onToggle}
|
||||||
{
|
onClick={() => handleRowClick(item)}
|
||||||
label: '입금',
|
details={[
|
||||||
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
{ label: '적요', value: item.note || '-' },
|
||||||
},
|
{
|
||||||
{
|
label: '입금',
|
||||||
label: '출금',
|
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
||||||
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
},
|
||||||
},
|
{
|
||||||
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
label: '출금',
|
||||||
{ label: '거래처', value: item.vendorName || '-' },
|
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
||||||
{ label: '입출금 유형', value: getTransactionTypeLabel(item) },
|
},
|
||||||
]}
|
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||||
actions={
|
{ label: '취급점', value: item.branch || '-' },
|
||||||
handlers.isSelected ? (
|
{ label: '예금주', value: item.depositorName || '-' },
|
||||||
<Button variant="outline" className="w-full" onClick={() => handleEditClick(item)}>
|
]}
|
||||||
<Pencil className="w-4 h-4 mr-2" /> 수정
|
/>
|
||||||
</Button>
|
);
|
||||||
) : undefined
|
},
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
data,
|
data,
|
||||||
pagination,
|
pagination,
|
||||||
summary,
|
summary,
|
||||||
accountOptions,
|
accountCategoryFilter,
|
||||||
accountFilter,
|
financialInstitutionFilter,
|
||||||
transactionTypeFilter,
|
financialInstitutionOptions,
|
||||||
sortOption,
|
sortOption,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
tableTotals,
|
tableTotals,
|
||||||
isLoading,
|
isLoading,
|
||||||
handleRefresh,
|
isBatchSaving,
|
||||||
handleEditClick,
|
localChanges,
|
||||||
getTransactionTypeLabel,
|
handleBatchSave,
|
||||||
|
handleExcelDownload,
|
||||||
|
handleCreateClick,
|
||||||
|
handleRowClick,
|
||||||
|
isRowModified,
|
||||||
|
isCellModified,
|
||||||
|
loadData,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UniversalListPage
|
<>
|
||||||
config={config}
|
<UniversalListPage
|
||||||
initialData={data}
|
config={config}
|
||||||
externalPagination={{
|
initialData={data}
|
||||||
currentPage,
|
externalPagination={{
|
||||||
totalPages: pagination.lastPage,
|
currentPage,
|
||||||
totalItems: pagination.total,
|
totalPages: pagination.lastPage,
|
||||||
itemsPerPage: 20,
|
totalItems: pagination.total,
|
||||||
onPageChange: setCurrentPage,
|
itemsPerPage: 20,
|
||||||
}}
|
onPageChange: setCurrentPage,
|
||||||
/>
|
}}
|
||||||
|
externalIsLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 수기 입력/수정 모달 */}
|
||||||
|
<TransactionFormModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
mode={modalMode}
|
||||||
|
transaction={selectedTransaction}
|
||||||
|
accountOptions={accountOptions}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// ===== 입출금 계좌조회 타입 정의 =====
|
// ===== 계좌 입출금 내역 타입 정의 =====
|
||||||
|
|
||||||
// 거래 구분
|
// 거래 구분
|
||||||
export type TransactionKind = 'deposit' | 'withdrawal';
|
export type TransactionKind = 'deposit' | 'withdrawal';
|
||||||
@@ -30,28 +30,52 @@ export type WithdrawalTransactionType =
|
|||||||
| 'vatPayment' // 부가세납부
|
| 'vatPayment' // 부가세납부
|
||||||
| 'other'; // 기타
|
| 'other'; // 기타
|
||||||
|
|
||||||
|
// 계좌 카테고리 타입 (구분 컬럼 표시용)
|
||||||
|
export type AccountCategory = 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||||
|
|
||||||
// 입출금 거래 레코드
|
// 입출금 거래 레코드
|
||||||
export interface BankTransaction {
|
export interface BankTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
bankName: string; // 은행명
|
bankName: string; // 은행명
|
||||||
accountName: string; // 계좌명
|
accountName: string; // 계좌명
|
||||||
transactionDate: string; // 거래일시
|
accountNumber?: string; // 계좌번호
|
||||||
type: TransactionKind; // 구분 (입금/출금)
|
accountCategory?: AccountCategory; // 계좌 카테고리 (은행계좌/대출계좌/증권계좌/보험계좌)
|
||||||
note?: string; // 적요
|
transactionDate: string; // 거래일
|
||||||
|
transactionTime?: string; // 거래시간 (HHMMSS)
|
||||||
|
type: TransactionKind; // 입금/출금
|
||||||
|
note?: string; // 적요/내용
|
||||||
vendorId?: string; // 거래처 ID
|
vendorId?: string; // 거래처 ID
|
||||||
vendorName?: string; // 거래처명
|
vendorName?: string; // 거래처명
|
||||||
depositorName?: string; // 입금자/수취인
|
depositorName?: string; // 상대계좌 예금주명
|
||||||
depositAmount: number; // 입금
|
depositAmount: number; // 입금
|
||||||
withdrawalAmount: number; // 출금
|
withdrawalAmount: number; // 출금
|
||||||
balance: number; // 잔액
|
balance: number; // 잔액
|
||||||
transactionType?: string; // 입출금 유형
|
transactionType?: string; // 입출금 유형
|
||||||
sourceId: string; // 원본 입금/출금 ID (상세 이동용)
|
sourceId: string; // 원본 입금/출금 ID
|
||||||
|
bankAccountId?: number; // 계좌 ID
|
||||||
|
memo?: string; // 메모
|
||||||
|
branch?: string; // 취급점
|
||||||
|
isManual: boolean; // 수기(true) / 연동(false)
|
||||||
|
modifiedFields?: string[]; // 수정된 필드 목록
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 타입
|
// 수기 입력/수정 폼 데이터
|
||||||
export type TransactionFilter = 'all' | 'deposit' | 'withdrawal';
|
export interface TransactionFormData {
|
||||||
|
bankAccountId: number | null; // 계좌 ID
|
||||||
|
transactionDate: string; // 거래일
|
||||||
|
transactionTime: string; // 거래시간
|
||||||
|
type: TransactionKind; // 거래유형 (입금/출금)
|
||||||
|
amount: number; // 금액
|
||||||
|
note: string; // 적요
|
||||||
|
depositorName: string; // 상대계좌 예금주명
|
||||||
|
memo: string; // 메모
|
||||||
|
branch: string; // 취급점
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계좌 카테고리 필터 (④ 구분)
|
||||||
|
export type AccountCategoryFilter = 'all' | 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||||
|
|
||||||
// 정렬 옵션
|
// 정렬 옵션
|
||||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||||
@@ -90,10 +114,21 @@ export const WITHDRAWAL_TYPE_LABELS: Record<WithdrawalTransactionType, string> =
|
|||||||
other: '기타',
|
other: '기타',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FILTER_OPTIONS: { value: TransactionFilter; label: string }[] = [
|
// 계좌 카테고리 라벨 (구분 컬럼 표시용)
|
||||||
{ value: 'all', label: '전체(선택)' },
|
export const ACCOUNT_CATEGORY_LABELS: Record<AccountCategory, string> = {
|
||||||
{ value: 'deposit', label: '입금/수입' },
|
bank_account: '은행계좌',
|
||||||
{ value: 'withdrawal', label: '출금' },
|
loan_account: '대출계좌',
|
||||||
|
securities_account: '증권계좌',
|
||||||
|
insurance_account: '보험계좌',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ④ 구분 셀렉트 옵션
|
||||||
|
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountCategoryFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'bank_account', label: '은행계좌' },
|
||||||
|
{ value: 'loan_account', label: '대출계좌' },
|
||||||
|
{ value: 'securities_account', label: '증권계좌' },
|
||||||
|
{ value: 'insurance_account', label: '보험계좌' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
@@ -127,4 +162,4 @@ export const TRANSACTION_TYPE_FILTER_OPTIONS: { value: string; label: string }[]
|
|||||||
{ value: 'loanRepayment', label: '차입금상환' },
|
{ value: 'loanRepayment', label: '차입금상환' },
|
||||||
{ value: 'vatPayment', label: '부가세납부' },
|
{ value: 'vatPayment', label: '부가세납부' },
|
||||||
{ value: 'other', label: '기타' },
|
{ value: 'other', label: '기타' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
||||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
|
||||||
import { cardTransactionDetailConfig } from './cardTransactionDetailConfig';
|
|
||||||
import type { CardTransaction } from './types';
|
|
||||||
import {
|
|
||||||
getCardTransactionById,
|
|
||||||
createCardTransaction,
|
|
||||||
updateCardTransaction,
|
|
||||||
deleteCardTransaction,
|
|
||||||
getCardList,
|
|
||||||
} from './actions';
|
|
||||||
import { useDevFill, generateCardTransactionData } from '@/components/dev';
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
interface CardTransactionDetailClientProps {
|
|
||||||
transactionId?: string;
|
|
||||||
initialMode?: DetailMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CardTransactionDetailClient({
|
|
||||||
transactionId,
|
|
||||||
initialMode = 'view',
|
|
||||||
}: CardTransactionDetailClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
|
||||||
const [transaction, setTransaction] = useState<CardTransaction | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(initialMode !== 'create');
|
|
||||||
|
|
||||||
// ===== DevFill: 자동 입력 기능 =====
|
|
||||||
useDevFill('cardTransaction', useCallback(async () => {
|
|
||||||
if (initialMode === 'create') {
|
|
||||||
// 카드 목록 가져오기
|
|
||||||
const cardResult = await getCardList();
|
|
||||||
const cards = cardResult.success ? cardResult.data : undefined;
|
|
||||||
|
|
||||||
const mockData = generateCardTransactionData({ cards });
|
|
||||||
setTransaction(mockData as unknown as CardTransaction);
|
|
||||||
toast.success('카드 사용내역 데이터가 자동 입력되었습니다.');
|
|
||||||
}
|
|
||||||
}, [initialMode]));
|
|
||||||
|
|
||||||
// ===== 데이터 로드 =====
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTransaction = async () => {
|
|
||||||
if (transactionId && initialMode !== 'create') {
|
|
||||||
setIsLoading(true);
|
|
||||||
const result = await getCardTransactionById(transactionId);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setTransaction(result.data);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '카드 사용내역을 불러오는데 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadTransaction();
|
|
||||||
}, [transactionId, initialMode]);
|
|
||||||
|
|
||||||
// ===== 저장/등록 핸들러 =====
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
const submitData = cardTransactionDetailConfig.transformSubmitData?.(formData) || formData;
|
|
||||||
|
|
||||||
if (!submitData.merchantName) {
|
|
||||||
toast.error('가맹점명을 입력해주세요.');
|
|
||||||
return { success: false, error: '가맹점명을 입력해주세요.' };
|
|
||||||
}
|
|
||||||
if (!submitData.amount || Number(submitData.amount) <= 0) {
|
|
||||||
toast.error('사용금액을 입력해주세요.');
|
|
||||||
return { success: false, error: '사용금액을 입력해주세요.' };
|
|
||||||
}
|
|
||||||
if (!submitData.usedAt) {
|
|
||||||
toast.error('사용일시를 선택해주세요.');
|
|
||||||
return { success: false, error: '사용일시를 선택해주세요.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result =
|
|
||||||
mode === 'create'
|
|
||||||
? await createCardTransaction(submitData as Parameters<typeof createCardTransaction>[0])
|
|
||||||
: await updateCardTransaction(transactionId!, submitData as Parameters<typeof updateCardTransaction>[1]);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(mode === 'create' ? '카드 사용내역이 등록되었습니다.' : '카드 사용내역이 수정되었습니다.');
|
|
||||||
router.push('/ko/accounting/card-transactions');
|
|
||||||
return { success: true };
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '저장에 실패했습니다.');
|
|
||||||
return { success: false, error: result.error };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mode, transactionId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 삭제 핸들러 =====
|
|
||||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
if (!transactionId) return { success: false, error: 'ID가 없습니다.' };
|
|
||||||
|
|
||||||
const result = await deleteCardTransaction(transactionId);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('카드 사용내역이 삭제되었습니다.');
|
|
||||||
router.push('/ko/accounting/card-transactions');
|
|
||||||
return { success: true };
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '삭제에 실패했습니다.');
|
|
||||||
return { success: false, error: result.error };
|
|
||||||
}
|
|
||||||
}, [transactionId, router]);
|
|
||||||
|
|
||||||
// ===== 모드 변경 핸들러 =====
|
|
||||||
const handleModeChange = useCallback(
|
|
||||||
(newMode: DetailMode) => {
|
|
||||||
if (newMode === 'edit' && transactionId) {
|
|
||||||
router.push(`/ko/accounting/card-transactions/${transactionId}?mode=edit`);
|
|
||||||
} else {
|
|
||||||
setMode(newMode);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[transactionId, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IntegratedDetailTemplate
|
|
||||||
config={cardTransactionDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
|
||||||
mode={mode}
|
|
||||||
initialData={transaction as unknown as Record<string, unknown> | undefined}
|
|
||||||
itemId={transactionId}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onModeChange={handleModeChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, Minus, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { CardTransaction, JournalEntryItem } from './types';
|
||||||
|
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||||
|
import { saveJournalEntries } from './actions';
|
||||||
|
|
||||||
|
interface JournalEntryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
transaction: CardTransaction | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyItem(transaction: CardTransaction | null): JournalEntryItem {
|
||||||
|
return {
|
||||||
|
supplyAmount: transaction?.supplyAmount || 0,
|
||||||
|
taxAmount: transaction?.taxAmount || 0,
|
||||||
|
totalAmount: transaction?.totalAmount || 0,
|
||||||
|
accountSubject: '',
|
||||||
|
deductionType: 'deductible',
|
||||||
|
vendorName: '',
|
||||||
|
description: '',
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }: JournalEntryModalProps) {
|
||||||
|
const [items, setItems] = useState<JournalEntryItem[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 모달 열릴 때 초기 항목 설정
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
if (isOpen && transaction) {
|
||||||
|
setItems([createEmptyItem(transaction)]);
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}, [transaction, onOpenChange]);
|
||||||
|
|
||||||
|
const updateItem = useCallback((index: number, key: keyof JournalEntryItem, value: string | number) => {
|
||||||
|
setItems(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const item = { ...updated[index], [key]: value };
|
||||||
|
// 합계금액 자동 계산
|
||||||
|
if (key === 'supplyAmount' || key === 'taxAmount') {
|
||||||
|
const supply = key === 'supplyAmount' ? (value as number) : item.supplyAmount;
|
||||||
|
const tax = key === 'taxAmount' ? (value as number) : item.taxAmount;
|
||||||
|
item.totalAmount = supply + tax;
|
||||||
|
}
|
||||||
|
updated[index] = item;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addItem = useCallback(() => {
|
||||||
|
setItems(prev => [...prev, createEmptyItem(null)]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeItem = useCallback((index: number) => {
|
||||||
|
setItems(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const journalTotal = items.reduce((sum, item) => sum + item.totalAmount, 0);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!transaction) return;
|
||||||
|
|
||||||
|
const hasEmptyAccount = items.some(item => !item.accountSubject);
|
||||||
|
if (hasEmptyAccount) {
|
||||||
|
toast.error('모든 분개 항목에 계정과목을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await saveJournalEntries(transaction.id, items);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('분개가 저장되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '분개 저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('분개 저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [transaction, items, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
if (!transaction) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>거래 분개</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 거래 정보 (읽기 전용 - FormField 대상 아님) */}
|
||||||
|
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">거래 정보</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">가맹점</Label>
|
||||||
|
<p className="text-sm font-medium mt-0.5">{transaction.merchantName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">사용일시</Label>
|
||||||
|
<p className="text-sm font-medium mt-0.5">{transaction.usedAt}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">공급가액</Label>
|
||||||
|
<p className="text-sm font-medium mt-0.5">{transaction.supplyAmount.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">세액</Label>
|
||||||
|
<p className="text-sm font-medium mt-0.5">{transaction.taxAmount.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">합계금액</Label>
|
||||||
|
<p className="text-sm font-medium mt-0.5">{transaction.totalAmount.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분개 항목 목록 */}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="border rounded-lg p-4 space-y-3 relative">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm">분개 항목</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
disabled={items.length <= 1}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급가액 + 세액 + 합계금액 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<FormField
|
||||||
|
type="number"
|
||||||
|
label="공급가액"
|
||||||
|
value={item.supplyAmount || ''}
|
||||||
|
onChange={(v) => updateItem(index, 'supplyAmount', Number(v) || 0)}
|
||||||
|
inputClassName="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="number"
|
||||||
|
label="세액"
|
||||||
|
value={item.taxAmount || ''}
|
||||||
|
onChange={(v) => updateItem(index, 'taxAmount', Number(v) || 0)}
|
||||||
|
inputClassName="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
{/* 합계금액 - readOnly (FormField 미지원, 커스텀 인터랙션 예외) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">합계금액</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.totalAmount}
|
||||||
|
readOnly
|
||||||
|
className="mt-1 h-8 text-sm bg-muted/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* Select - FormField 예외 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">계정과목</Label>
|
||||||
|
<Select
|
||||||
|
value={item.accountSubject || 'none'}
|
||||||
|
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* Select - FormField 예외 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공제</Label>
|
||||||
|
<Select
|
||||||
|
value={item.deductionType}
|
||||||
|
onValueChange={(v) => updateItem(index, 'deductionType', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DEDUCTION_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="증빙/판매자상호"
|
||||||
|
value={item.vendorName}
|
||||||
|
onChange={(v) => updateItem(index, 'vendorName', v)}
|
||||||
|
placeholder="내용"
|
||||||
|
inputClassName="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내역 + 메모 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
label="내역"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(v) => updateItem(index, 'description', v)}
|
||||||
|
placeholder="내역"
|
||||||
|
inputClassName="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="메모"
|
||||||
|
value={item.memo}
|
||||||
|
onChange={(v) => updateItem(index, 'memo', v)}
|
||||||
|
inputClassName="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 분개 항목 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={addItem}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
분개 항목 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 분개 합계 */}
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">분개 합계</span>
|
||||||
|
<span className="text-lg font-bold">{journalTotal.toLocaleString()}원</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'저장'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { TimePicker } from '@/components/ui/time-picker';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import type { ManualInputFormData } from './types';
|
||||||
|
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||||
|
import { getCardList, createCardTransaction } from './actions';
|
||||||
|
|
||||||
|
interface ManualInputModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: ManualInputFormData = {
|
||||||
|
cardId: '',
|
||||||
|
usedDate: new Date().toISOString().slice(0, 10),
|
||||||
|
usedTime: '',
|
||||||
|
approvalNumber: '',
|
||||||
|
approvalType: 'approved',
|
||||||
|
supplyAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
merchantName: '',
|
||||||
|
businessNumber: '',
|
||||||
|
deductionType: 'deductible',
|
||||||
|
accountSubject: '',
|
||||||
|
vendorName: '',
|
||||||
|
description: '',
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputModalProps) {
|
||||||
|
const [formData, setFormData] = useState<ManualInputFormData>(initialFormData);
|
||||||
|
const [cardOptions, setCardOptions] = useState<Array<{ id: number; name: string; cardNumber: string }>>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isLoadingCards, setIsLoadingCards] = useState(false);
|
||||||
|
|
||||||
|
// 카드 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setIsLoadingCards(true);
|
||||||
|
getCardList()
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) setCardOptions(result.data);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoadingCards(false));
|
||||||
|
setFormData(initialFormData);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((key: keyof ManualInputFormData, value: string | number) => {
|
||||||
|
setFormData(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalAmount = formData.supplyAmount + formData.taxAmount;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.cardId) {
|
||||||
|
toast.error('카드를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.usedDate) {
|
||||||
|
toast.error('사용일을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.supplyAmount <= 0 && formData.taxAmount <= 0) {
|
||||||
|
toast.error('공급가액 또는 세액을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await createCardTransaction(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('카드사용 내역이 등록되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('등록 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>카드사용 수기 입력</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 카드 선택 (동적 API Select - FormField 예외) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
카드 선택 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cardId}
|
||||||
|
onValueChange={(v) => handleChange('cardId', v)}
|
||||||
|
disabled={isLoadingCards}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="카드를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cardOptions.map(card => (
|
||||||
|
<SelectItem key={card.id} value={String(card.id)}>
|
||||||
|
{card.cardNumber} {card.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용일 + 사용시간 (공통 DatePicker/TimePicker) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
사용일 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={formData.usedDate}
|
||||||
|
onChange={(v) => handleChange('usedDate', v)}
|
||||||
|
placeholder="날짜 선택"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">사용시간</Label>
|
||||||
|
<TimePicker
|
||||||
|
value={formData.usedTime}
|
||||||
|
onChange={(v) => handleChange('usedTime', v)}
|
||||||
|
placeholder="시간 선택"
|
||||||
|
showSeconds
|
||||||
|
secondStep={1}
|
||||||
|
minuteStep={1}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 승인번호 + 승인유형 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="승인번호"
|
||||||
|
value={formData.approvalNumber}
|
||||||
|
onChange={(v) => handleChange('approvalNumber', v)}
|
||||||
|
placeholder="승인번호"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
승인유형 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={formData.approvalType}
|
||||||
|
onValueChange={(v) => handleChange('approvalType', v)}
|
||||||
|
className="flex items-center gap-4 mt-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="approved" id="approval-approved" />
|
||||||
|
<Label htmlFor="approval-approved" className="text-sm">승인</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="cancelled" id="approval-cancelled" />
|
||||||
|
<Label htmlFor="approval-cancelled" className="text-sm">취소</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급가액 + 세액 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
type="number"
|
||||||
|
label="공급가액"
|
||||||
|
required
|
||||||
|
value={formData.supplyAmount || ''}
|
||||||
|
onChange={(v) => handleChange('supplyAmount', Number(v) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="number"
|
||||||
|
label="세액"
|
||||||
|
required
|
||||||
|
value={formData.taxAmount || ''}
|
||||||
|
onChange={(v) => handleChange('taxAmount', Number(v) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가맹점명 + 사업자번호 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="가맹점명"
|
||||||
|
value={formData.merchantName}
|
||||||
|
onChange={(v) => handleChange('merchantName', v)}
|
||||||
|
placeholder="가맹점명"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="businessNumber"
|
||||||
|
label="사업자번호"
|
||||||
|
value={formData.businessNumber}
|
||||||
|
onChange={(v) => handleChange('businessNumber', v)}
|
||||||
|
placeholder="123-12-12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공제여부 + 계정과목 (Select - FormField 예외) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
공제여부 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.deductionType}
|
||||||
|
onValueChange={(v) => handleChange('deductionType', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DEDUCTION_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">계정과목</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.accountSubject || 'none'}
|
||||||
|
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 증빙/판매자상호 + 내역 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="증빙/판매자상호"
|
||||||
|
value={formData.vendorName}
|
||||||
|
onChange={(v) => handleChange('vendorName', v)}
|
||||||
|
placeholder="증빙/판매자상호"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="내역"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(v) => handleChange('description', v)}
|
||||||
|
placeholder="내역"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메모 */}
|
||||||
|
<FormField
|
||||||
|
type="textarea"
|
||||||
|
label="메모"
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(v) => handleChange('memo', v)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 합계 금액 */}
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">합계 금액 (공급가액 + 세액)</span>
|
||||||
|
<span className="text-lg font-bold">{totalAmount.toLocaleString()}원</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
등록 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'등록'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
import { buildApiUrl } from '@/lib/api/query-params';
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
import type { CardTransaction } from './types';
|
import type { CardTransaction, ManualInputFormData, InlineEditData, JournalEntryItem } from './types';
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
interface CardTransactionApiItem {
|
interface CardTransactionApiItem {
|
||||||
@@ -14,8 +14,17 @@ interface CardTransactionApiItem {
|
|||||||
used_at: string | null;
|
used_at: string | null;
|
||||||
merchant_name: string | null;
|
merchant_name: string | null;
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
|
supply_amount?: number | string;
|
||||||
|
tax_amount?: number | string;
|
||||||
|
business_number?: string | null;
|
||||||
account_code: string | null;
|
account_code: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
deduction_type?: string | null;
|
||||||
|
approval_number?: string | null;
|
||||||
|
approval_type?: string | null;
|
||||||
|
is_hidden?: boolean;
|
||||||
|
is_manual?: boolean;
|
||||||
|
vendor_name?: string | null;
|
||||||
card: {
|
card: {
|
||||||
id: number;
|
id: number;
|
||||||
card_company: string;
|
card_company: string;
|
||||||
@@ -43,26 +52,131 @@ function transformItem(item: CardTransactionApiItem): CardTransaction {
|
|||||||
const usedAtDate = new Date(usedAtRaw);
|
const usedAtDate = new Date(usedAtRaw);
|
||||||
const usedAt = item.used_at ? usedAtDate.toISOString().slice(0, 16).replace('T', ' ') : item.withdrawal_date;
|
const usedAt = item.used_at ? usedAtDate.toISOString().slice(0, 16).replace('T', ' ') : item.withdrawal_date;
|
||||||
|
|
||||||
|
const totalAmount = typeof item.amount === 'string' ? parseFloat(item.amount) : item.amount;
|
||||||
|
const supplyAmount = item.supply_amount
|
||||||
|
? (typeof item.supply_amount === 'string' ? parseFloat(item.supply_amount) : item.supply_amount)
|
||||||
|
: totalAmount;
|
||||||
|
const taxAmount = item.tax_amount
|
||||||
|
? (typeof item.tax_amount === 'string' ? parseFloat(item.tax_amount) : item.tax_amount)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
card: cardDisplay,
|
cardCompany: card?.card_company || '-',
|
||||||
|
card: card ? `****${card.card_number_last4}` : '-',
|
||||||
cardName: card?.card_name || '-',
|
cardName: card?.card_name || '-',
|
||||||
user: card?.assigned_user?.name || '-',
|
user: card?.assigned_user?.name || '-',
|
||||||
usedAt,
|
usedAt,
|
||||||
merchantName: item.merchant_name || item.description || '-',
|
merchantName: item.merchant_name || '-',
|
||||||
amount: typeof item.amount === 'string' ? parseFloat(item.amount) : item.amount,
|
businessNumber: item.business_number || '-',
|
||||||
|
vendorName: item.vendor_name || '',
|
||||||
|
supplyAmount,
|
||||||
|
taxAmount,
|
||||||
|
totalAmount: supplyAmount + taxAmount || totalAmount,
|
||||||
|
deductionType: item.deduction_type || 'deductible',
|
||||||
|
accountSubject: item.account_code || '',
|
||||||
|
description: item.description || '',
|
||||||
|
approvalNumber: item.approval_number || undefined,
|
||||||
|
approvalType: item.approval_type || undefined,
|
||||||
|
isHidden: !!item.is_hidden,
|
||||||
|
hiddenAt: (item as unknown as { hidden_at?: string }).hidden_at,
|
||||||
|
isManual: !!item.is_manual,
|
||||||
|
memo: '',
|
||||||
|
amount: totalAmount,
|
||||||
usageType: item.usage_type || '',
|
usageType: item.usage_type || '',
|
||||||
createdAt: item.created_at,
|
createdAt: item.created_at,
|
||||||
updatedAt: item.updated_at,
|
updatedAt: item.updated_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Mock 데이터 (개발용) =====
|
||||||
|
function generateMockData(): CardTransaction[] {
|
||||||
|
const cards = [
|
||||||
|
{ company: '신한', number: '****3456', name: '법인카드1' },
|
||||||
|
{ company: 'KB국민', number: '****7890', name: '법인카드2' },
|
||||||
|
{ company: '현대', number: '****1234', name: '복리후생카드' },
|
||||||
|
];
|
||||||
|
const merchants = [
|
||||||
|
{ name: '스타벅스 강남점', biz: '123-45-67890', vendor: '스타벅스코리아' },
|
||||||
|
{ name: 'GS25 역삼점', biz: '234-56-78901', vendor: 'GS리테일' },
|
||||||
|
{ name: '쿠팡', biz: '345-67-89012', vendor: '쿠팡(주)' },
|
||||||
|
{ name: '교보문고 광화문', biz: '456-78-90123', vendor: '교보문고' },
|
||||||
|
{ name: '현대주유소', biz: '567-89-01234', vendor: '현대오일뱅크' },
|
||||||
|
{ name: '삼성전자 서비스', biz: '678-90-12345', vendor: '삼성전자서비스(주)' },
|
||||||
|
{ name: 'CJ대한통운', biz: '789-01-23456', vendor: 'CJ대한통운(주)' },
|
||||||
|
{ name: '올리브영 선릉점', biz: '890-12-34567', vendor: 'CJ올리브영' },
|
||||||
|
];
|
||||||
|
const descriptions = ['사무용품 구매', '직원 식대', '택배비', '교통비', '복리후생비', '광고비', '소모품비', '통신비'];
|
||||||
|
const accounts = ['', 'purchasePayment', 'expenses', 'rent', 'salary', 'insurance', 'utilities'];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return Array.from({ length: 15 }, (_, i) => {
|
||||||
|
const card = cards[i % cards.length];
|
||||||
|
const merchant = merchants[i % merchants.length];
|
||||||
|
const supply = Math.round((Math.random() * 500000 + 10000) / 100) * 100;
|
||||||
|
const tax = Math.round(supply * 0.1);
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
const timeStr = `${String(9 + (i % 10)).padStart(2, '0')}:${String((i * 17) % 60).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(1000 + i),
|
||||||
|
cardCompany: card.company,
|
||||||
|
card: card.number,
|
||||||
|
cardName: card.name,
|
||||||
|
user: '홍길동',
|
||||||
|
usedAt: `${dateStr} ${timeStr}`,
|
||||||
|
merchantName: merchant.name,
|
||||||
|
businessNumber: merchant.biz,
|
||||||
|
vendorName: merchant.vendor,
|
||||||
|
supplyAmount: supply,
|
||||||
|
taxAmount: tax,
|
||||||
|
totalAmount: supply + tax,
|
||||||
|
deductionType: i % 3 === 0 ? 'non_deductible' : 'deductible',
|
||||||
|
accountSubject: accounts[i % accounts.length],
|
||||||
|
description: descriptions[i % descriptions.length],
|
||||||
|
isHidden: false,
|
||||||
|
isManual: i % 5 === 0,
|
||||||
|
memo: '',
|
||||||
|
amount: supply + tax,
|
||||||
|
usageType: '',
|
||||||
|
createdAt: dateStr,
|
||||||
|
updatedAt: dateStr,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockHiddenData(): CardTransaction[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '9001', cardCompany: '신한', card: '****3456', cardName: '법인카드1',
|
||||||
|
user: '홍길동', usedAt: '2026-02-10 14:30', merchantName: '이마트 역삼점',
|
||||||
|
businessNumber: '111-22-33333', vendorName: '이마트(주)',
|
||||||
|
supplyAmount: 45000, taxAmount: 4500, totalAmount: 49500,
|
||||||
|
deductionType: 'deductible', accountSubject: '', description: '사무용품',
|
||||||
|
isHidden: true, hiddenAt: '2026-02-12 09:15', isManual: false, memo: '', amount: 49500, usageType: '',
|
||||||
|
createdAt: '2026-02-10', updatedAt: '2026-02-10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9002', cardCompany: 'KB국민', card: '****7890', cardName: '법인카드2',
|
||||||
|
user: '홍길동', usedAt: '2026-02-08 11:15', merchantName: '다이소 강남점',
|
||||||
|
businessNumber: '222-33-44444', vendorName: '아성다이소',
|
||||||
|
supplyAmount: 12000, taxAmount: 1200, totalAmount: 13200,
|
||||||
|
deductionType: 'non_deductible', accountSubject: '', description: '소모품',
|
||||||
|
isHidden: true, hiddenAt: '2026-02-11 16:40', isManual: false, memo: '', amount: 13200, usageType: '',
|
||||||
|
createdAt: '2026-02-08', updatedAt: '2026-02-08',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 카드 거래 목록 조회 =====
|
// ===== 카드 거래 목록 조회 =====
|
||||||
export async function getCardTransactionList(params?: {
|
export async function getCardTransactionList(params?: {
|
||||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||||
cardId?: number; search?: string; sortBy?: string; sortDir?: 'asc' | 'desc';
|
cardId?: number; search?: string; sortBy?: string; sortDir?: 'asc' | 'desc';
|
||||||
|
isHidden?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return executePaginatedAction<CardTransactionApiItem, CardTransaction>({
|
const result = await executePaginatedAction<CardTransactionApiItem, CardTransaction>({
|
||||||
url: buildApiUrl('/api/v1/card-transactions', {
|
url: buildApiUrl('/api/v1/card-transactions', {
|
||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.perPage,
|
per_page: params?.perPage,
|
||||||
@@ -72,17 +186,29 @@ export async function getCardTransactionList(params?: {
|
|||||||
search: params?.search,
|
search: params?.search,
|
||||||
sort_by: params?.sortBy,
|
sort_by: params?.sortBy,
|
||||||
sort_dir: params?.sortDir,
|
sort_dir: params?.sortDir,
|
||||||
|
is_hidden: params?.isHidden,
|
||||||
}),
|
}),
|
||||||
transform: transformItem,
|
transform: transformItem,
|
||||||
errorMessage: '카드 거래 조회에 실패했습니다.',
|
errorMessage: '카드 거래 조회에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API 빈 응답 시 mock fallback (개발용)
|
||||||
|
if (result.success && result.data.length === 0) {
|
||||||
|
const mockData = generateMockData();
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: mockData,
|
||||||
|
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카드 거래 요약 통계 =====
|
// ===== 카드 거래 요약 통계 =====
|
||||||
export async function getCardTransactionSummary(params?: {
|
export async function getCardTransactionSummary(params?: {
|
||||||
startDate?: string; endDate?: string;
|
startDate?: string; endDate?: string;
|
||||||
}): Promise<ActionResult<{ previousMonthTotal: number; currentMonthTotal: number; totalCount: number; totalAmount: number }>> {
|
}): Promise<ActionResult<{ previousMonthTotal: number; currentMonthTotal: number; totalCount: number; totalAmount: number }>> {
|
||||||
return executeServerAction({
|
const result = await executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/card-transactions/summary', {
|
url: buildApiUrl('/api/v1/card-transactions/summary', {
|
||||||
start_date: params?.startDate,
|
start_date: params?.startDate,
|
||||||
end_date: params?.endDate,
|
end_date: params?.endDate,
|
||||||
@@ -95,6 +221,15 @@ export async function getCardTransactionSummary(params?: {
|
|||||||
}),
|
}),
|
||||||
errorMessage: '요약 조회에 실패했습니다.',
|
errorMessage: '요약 조회에 실패했습니다.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock fallback (개발용)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { previousMonthTotal: 8542300, currentMonthTotal: 10802897, totalCount: 15, totalAmount: 10802897 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카드 거래 단건 조회 =====
|
// ===== 카드 거래 단건 조회 =====
|
||||||
@@ -106,19 +241,33 @@ export async function getCardTransactionById(id: string): Promise<ActionResult<C
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카드 거래 등록 =====
|
// ===== 카드 거래 등록 (수기 입력) =====
|
||||||
export async function createCardTransaction(data: {
|
export async function createCardTransaction(data: ManualInputFormData): Promise<ActionResult<CardTransaction>> {
|
||||||
cardId?: number; usedAt: string; merchantName: string; amount: number; memo?: string; usageType?: string;
|
const usedAt = data.usedTime
|
||||||
}): Promise<ActionResult<CardTransaction>> {
|
? `${data.usedDate} ${data.usedTime}`
|
||||||
|
: data.usedDate;
|
||||||
|
|
||||||
return executeServerAction({
|
return executeServerAction({
|
||||||
url: buildApiUrl('/api/v1/card-transactions'),
|
url: buildApiUrl('/api/v1/card-transactions'),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
card_id: data.cardId, used_at: data.usedAt, merchant_name: data.merchantName,
|
card_id: data.cardId ? Number(data.cardId) : undefined,
|
||||||
amount: data.amount, description: data.memo,
|
used_at: usedAt,
|
||||||
account_code: data.usageType === 'unset' ? null : data.usageType,
|
merchant_name: data.merchantName,
|
||||||
|
supply_amount: data.supplyAmount,
|
||||||
|
tax_amount: data.taxAmount,
|
||||||
|
amount: data.supplyAmount + data.taxAmount,
|
||||||
|
business_number: data.businessNumber || undefined,
|
||||||
|
deduction_type: data.deductionType,
|
||||||
|
account_code: data.accountSubject || undefined,
|
||||||
|
approval_number: data.approvalNumber || undefined,
|
||||||
|
approval_type: data.approvalType,
|
||||||
|
description: data.description || undefined,
|
||||||
|
vendor_name: data.vendorName || undefined,
|
||||||
|
memo: data.memo || undefined,
|
||||||
|
is_manual: true,
|
||||||
},
|
},
|
||||||
transform: (data: CardTransactionApiItem) => transformItem(data),
|
transform: (resp: CardTransactionApiItem) => transformItem(resp),
|
||||||
errorMessage: '등록에 실패했습니다.',
|
errorMessage: '등록에 실패했습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -134,7 +283,7 @@ export async function updateCardTransaction(id: string, data: {
|
|||||||
used_at: data.usedAt, merchant_name: data.merchantName, amount: data.amount,
|
used_at: data.usedAt, merchant_name: data.merchantName, amount: data.amount,
|
||||||
description: data.memo, account_code: data.usageType === 'unset' ? null : data.usageType,
|
description: data.memo, account_code: data.usageType === 'unset' ? null : data.usageType,
|
||||||
},
|
},
|
||||||
transform: (data: CardTransactionApiItem) => transformItem(data),
|
transform: (resp: CardTransactionApiItem) => transformItem(resp),
|
||||||
errorMessage: '수정에 실패했습니다.',
|
errorMessage: '수정에 실패했습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -177,3 +326,112 @@ export async function bulkUpdateAccountCode(ids: number[], accountCode: string):
|
|||||||
});
|
});
|
||||||
return { success: result.success, updatedCount: result.data?.updatedCount, error: result.error };
|
return { success: result.success, updatedCount: result.data?.updatedCount, error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 인라인 편집 일괄 저장 =====
|
||||||
|
export async function bulkSaveInlineEdits(
|
||||||
|
edits: Record<string, InlineEditData>
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const items = Object.entries(edits).map(([id, data]) => ({
|
||||||
|
id: Number(id),
|
||||||
|
deduction_type: data.deductionType,
|
||||||
|
account_code: data.accountSubject,
|
||||||
|
description: data.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/card-transactions/bulk-update'),
|
||||||
|
method: 'PUT',
|
||||||
|
body: { items },
|
||||||
|
errorMessage: '일괄 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래 숨김 처리 =====
|
||||||
|
export async function hideTransaction(id: string): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/card-transactions/${id}/hide`),
|
||||||
|
method: 'PUT',
|
||||||
|
errorMessage: '숨김 처리에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래 숨김 해제 (복원) =====
|
||||||
|
export async function unhideTransaction(id: string): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/card-transactions/${id}/unhide`),
|
||||||
|
method: 'PUT',
|
||||||
|
errorMessage: '복원에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 숨김 처리된 거래 목록 =====
|
||||||
|
export async function getHiddenTransactions(params?: {
|
||||||
|
startDate?: string; endDate?: string;
|
||||||
|
}) {
|
||||||
|
const result = await executePaginatedAction<CardTransactionApiItem, CardTransaction>({
|
||||||
|
url: buildApiUrl('/api/v1/card-transactions', {
|
||||||
|
start_date: params?.startDate,
|
||||||
|
end_date: params?.endDate,
|
||||||
|
is_hidden: true,
|
||||||
|
}),
|
||||||
|
transform: transformItem,
|
||||||
|
errorMessage: '숨김 거래 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fallback (개발용)
|
||||||
|
if (result.success && result.data.length === 0) {
|
||||||
|
const mockHidden = generateMockHiddenData();
|
||||||
|
return { ...result, data: mockHidden, pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockHidden.length } };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 항목 저장 =====
|
||||||
|
export async function saveJournalEntries(
|
||||||
|
transactionId: string,
|
||||||
|
items: JournalEntryItem[]
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/card-transactions/${transactionId}/journal-entries`),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
items: items.map(item => ({
|
||||||
|
supply_amount: item.supplyAmount,
|
||||||
|
tax_amount: item.taxAmount,
|
||||||
|
account_code: item.accountSubject,
|
||||||
|
deduction_type: item.deductionType,
|
||||||
|
vendor_name: item.vendorName,
|
||||||
|
description: item.description,
|
||||||
|
memo: item.memo,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
errorMessage: '분개 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 항목 조회 =====
|
||||||
|
export async function getJournalEntries(
|
||||||
|
transactionId: string
|
||||||
|
): Promise<ActionResult<JournalEntryItem[]>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/card-transactions/${transactionId}/journal-entries`),
|
||||||
|
transform: (data: { items?: Array<{
|
||||||
|
id?: number; supply_amount: number; tax_amount: number;
|
||||||
|
account_code: string; deduction_type: string; vendor_name: string;
|
||||||
|
description: string; memo: string;
|
||||||
|
}> }) => {
|
||||||
|
return (data.items || []).map(item => ({
|
||||||
|
id: item.id ? String(item.id) : undefined,
|
||||||
|
supplyAmount: item.supply_amount,
|
||||||
|
taxAmount: item.tax_amount,
|
||||||
|
totalAmount: item.supply_amount + item.tax_amount,
|
||||||
|
accountSubject: item.account_code || '',
|
||||||
|
deductionType: item.deduction_type || 'deductible',
|
||||||
|
vendorName: item.vendor_name || '',
|
||||||
|
description: item.description || '',
|
||||||
|
memo: item.memo || '',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
errorMessage: '분개 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { CreditCard } from 'lucide-react';
|
|
||||||
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
|
||||||
import type { CardTransaction } from './types';
|
|
||||||
import { USAGE_TYPE_OPTIONS } from './types';
|
|
||||||
import { getCardList } from './actions';
|
|
||||||
|
|
||||||
// ===== 필드 정의 =====
|
|
||||||
const fields: FieldDefinition[] = [
|
|
||||||
// 카드 선택 (create에서만 선택 가능)
|
|
||||||
{
|
|
||||||
key: 'cardId',
|
|
||||||
label: '카드',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
placeholder: '카드를 선택해주세요',
|
|
||||||
fetchOptions: async () => {
|
|
||||||
const result = await getCardList();
|
|
||||||
if (result.success) {
|
|
||||||
return result.data.map((card) => ({
|
|
||||||
value: String(card.id),
|
|
||||||
label: `${card.name} (${card.cardNumber})`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
// 사용일시
|
|
||||||
{
|
|
||||||
key: 'usedAt',
|
|
||||||
label: '사용일시',
|
|
||||||
type: 'datetime-local',
|
|
||||||
required: true,
|
|
||||||
placeholder: '사용일시를 선택해주세요',
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
// 가맹점명
|
|
||||||
{
|
|
||||||
key: 'merchantName',
|
|
||||||
label: '가맹점명',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
placeholder: '가맹점명을 입력해주세요',
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
// 사용금액
|
|
||||||
{
|
|
||||||
key: 'amount',
|
|
||||||
label: '사용금액',
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
placeholder: '사용금액을 입력해주세요',
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
// 적요
|
|
||||||
{
|
|
||||||
key: 'memo',
|
|
||||||
label: '적요',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '적요를 입력해주세요',
|
|
||||||
gridSpan: 2,
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
// 사용유형
|
|
||||||
{
|
|
||||||
key: 'usageType',
|
|
||||||
label: '사용유형',
|
|
||||||
type: 'select',
|
|
||||||
placeholder: '선택',
|
|
||||||
options: USAGE_TYPE_OPTIONS.map((opt) => ({
|
|
||||||
value: opt.value,
|
|
||||||
label: opt.label,
|
|
||||||
})),
|
|
||||||
disabled: (mode) => mode === 'view',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== Config 정의 =====
|
|
||||||
export const cardTransactionDetailConfig: DetailConfig = {
|
|
||||||
title: '카드 사용내역',
|
|
||||||
description: '카드 사용 내역을 등록/수정합니다',
|
|
||||||
icon: CreditCard,
|
|
||||||
basePath: '/accounting/card-transactions',
|
|
||||||
fields,
|
|
||||||
gridColumns: 2,
|
|
||||||
actions: {
|
|
||||||
showBack: true,
|
|
||||||
showDelete: true,
|
|
||||||
showEdit: true,
|
|
||||||
backLabel: '목록',
|
|
||||||
deleteLabel: '삭제',
|
|
||||||
editLabel: '수정',
|
|
||||||
deleteConfirmMessage: {
|
|
||||||
title: '카드 사용내역 삭제',
|
|
||||||
description: '이 카드 사용내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
|
|
||||||
const record = data as unknown as CardTransaction;
|
|
||||||
// DevFill에서 전달된 cardId 또는 기존 데이터의 cardId
|
|
||||||
const inputCardId = (data as Record<string, unknown>).cardId;
|
|
||||||
// usedAt을 datetime-local 형식으로 변환 (YYYY-MM-DDTHH:mm)
|
|
||||||
let usedAtFormatted = '';
|
|
||||||
if (record.usedAt) {
|
|
||||||
// "2025-01-22 14:30" 형식을 "2025-01-22T14:30" 형식으로 변환
|
|
||||||
usedAtFormatted = record.usedAt.replace(' ', 'T').slice(0, 16);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
cardId: inputCardId ? String(inputCardId) : '', // create 모드에서 DevFill로 전달된 cardId 사용
|
|
||||||
usedAt: usedAtFormatted,
|
|
||||||
merchantName: record.merchantName || '',
|
|
||||||
amount: record.amount || 0,
|
|
||||||
memo: record.memo || '',
|
|
||||||
usageType: record.usageType || 'unset',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
transformSubmitData: (formData: Record<string, unknown>) => {
|
|
||||||
return {
|
|
||||||
cardId: formData.cardId ? Number(formData.cardId) : undefined,
|
|
||||||
usedAt: formData.usedAt as string,
|
|
||||||
merchantName: formData.merchantName as string,
|
|
||||||
amount: Number(formData.amount),
|
|
||||||
memo: formData.memo as string,
|
|
||||||
usageType: formData.usageType as string,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,83 @@
|
|||||||
// ===== 카드 내역 조회 타입 정의 =====
|
// ===== 카드 사용내역 타입 정의 =====
|
||||||
|
|
||||||
// 카드 거래 레코드
|
// 카드 거래 레코드
|
||||||
export interface CardTransaction {
|
export interface CardTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
card: string; // 카드 (신한 1234 등)
|
cardCompany: string; // 카드사 (신한, KB 등)
|
||||||
|
card: string; // 카드번호 표시 (****1234 등)
|
||||||
cardName: string; // 카드명 (법인카드1 등)
|
cardName: string; // 카드명 (법인카드1 등)
|
||||||
user: string; // 사용자
|
user: string; // 사용자
|
||||||
usedAt: string; // 사용일시
|
usedAt: string; // 사용일시
|
||||||
merchantName: string; // 가맹점명
|
merchantName: string; // 가맹점명
|
||||||
amount: number; // 사용금액
|
businessNumber: string; // 사업자번호
|
||||||
memo?: string; // 적요
|
vendorName: string; // 증빙/판매자상호
|
||||||
usageType: string; // 사용유형
|
supplyAmount: number; // 공급가액
|
||||||
|
taxAmount: number; // 세액
|
||||||
|
totalAmount: number; // 합계금액 (공급가액 + 세액)
|
||||||
|
deductionType: string; // 공제여부 (deductible | non_deductible)
|
||||||
|
accountSubject: string; // 계정과목
|
||||||
|
description: string; // 내역
|
||||||
|
approvalNumber?: string; // 승인번호
|
||||||
|
approvalType?: string; // 승인유형 (approved | cancelled)
|
||||||
|
isHidden: boolean; // 숨김 여부
|
||||||
|
hiddenAt?: string; // 숨김일시
|
||||||
|
isManual: boolean; // 수기 입력 여부
|
||||||
|
memo?: string; // 메모
|
||||||
|
// 하위 호환용 (기존 필드)
|
||||||
|
amount: number; // 사용금액 (totalAmount과 동일)
|
||||||
|
usageType: string; // 사용유형 (기존 호환)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 분개 항목
|
||||||
|
export interface JournalEntryItem {
|
||||||
|
id?: string;
|
||||||
|
supplyAmount: number; // 공급가액
|
||||||
|
taxAmount: number; // 세액
|
||||||
|
totalAmount: number; // 합계금액
|
||||||
|
accountSubject: string; // 계정과목
|
||||||
|
deductionType: string; // 공제여부
|
||||||
|
vendorName: string; // 증빙/판매자상호
|
||||||
|
description: string; // 내역
|
||||||
|
memo: string; // 메모
|
||||||
|
}
|
||||||
|
|
||||||
|
// 분개 데이터
|
||||||
|
export interface JournalEntry {
|
||||||
|
transactionId: string;
|
||||||
|
items: JournalEntryItem[];
|
||||||
|
totalAmount: number; // 분개 합계
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인라인 편집 데이터
|
||||||
|
export interface InlineEditData {
|
||||||
|
deductionType?: string;
|
||||||
|
accountSubject?: string;
|
||||||
|
vendorName?: string;
|
||||||
|
description?: string;
|
||||||
|
supplyAmount?: number;
|
||||||
|
taxAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수기 입력 폼 데이터
|
||||||
|
export interface ManualInputFormData {
|
||||||
|
cardId: string;
|
||||||
|
usedDate: string; // yyyy-MM-dd
|
||||||
|
usedTime: string; // HH:mm:ss
|
||||||
|
approvalNumber: string;
|
||||||
|
approvalType: 'approved' | 'cancelled';
|
||||||
|
supplyAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
merchantName: string;
|
||||||
|
businessNumber: string;
|
||||||
|
deductionType: string;
|
||||||
|
accountSubject: string;
|
||||||
|
vendorName: string; // 증빙/판매자상호
|
||||||
|
description: string; // 내역
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 정렬 옵션
|
// 정렬 옵션
|
||||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||||
|
|
||||||
@@ -27,6 +90,12 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|||||||
{ value: 'amountLow', label: '금액낮은순' },
|
{ value: 'amountLow', label: '금액낮은순' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ===== 공제여부 옵션 =====
|
||||||
|
export const DEDUCTION_OPTIONS = [
|
||||||
|
{ value: 'deductible', label: '공제' },
|
||||||
|
{ value: 'non_deductible', label: '불공제' },
|
||||||
|
];
|
||||||
|
|
||||||
// ===== 사용유형 옵션 =====
|
// ===== 사용유형 옵션 =====
|
||||||
export const USAGE_TYPE_OPTIONS = [
|
export const USAGE_TYPE_OPTIONS = [
|
||||||
{ value: 'unset', label: '미설정' },
|
{ value: 'unset', label: '미설정' },
|
||||||
@@ -49,9 +118,9 @@ export const USAGE_TYPE_OPTIONS = [
|
|||||||
{ value: 'miscellaneous', label: '잡비' },
|
{ value: 'miscellaneous', label: '잡비' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
// ===== 계정과목 옵션 =====
|
||||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||||
{ value: 'unset', label: '미설정' },
|
{ value: '', label: '선택' },
|
||||||
{ value: 'purchasePayment', label: '매입대금' },
|
{ value: 'purchasePayment', label: '매입대금' },
|
||||||
{ value: 'advance', label: '선급금' },
|
{ value: 'advance', label: '선급금' },
|
||||||
{ value: 'suspense', label: '가지급금' },
|
{ value: 'suspense', label: '가지급금' },
|
||||||
@@ -67,4 +136,14 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
|
|||||||
{ value: 'utilities', label: '공과금' },
|
{ value: 'utilities', label: '공과금' },
|
||||||
{ value: 'expenses', label: '경비' },
|
{ value: 'expenses', label: '경비' },
|
||||||
{ value: 'other', label: '기타' },
|
{ value: 'other', label: '기타' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ===== 월 프리셋 옵션 =====
|
||||||
|
export const MONTH_PRESETS = [
|
||||||
|
{ label: '이번달', value: 0 },
|
||||||
|
{ label: '저번달', value: -1 },
|
||||||
|
{ label: 'D-2달', value: -2 },
|
||||||
|
{ label: 'D-3달', value: -3 },
|
||||||
|
{ label: 'D-4달', value: -4 },
|
||||||
|
{ label: 'D-5달', value: -5 },
|
||||||
|
];
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
(으)로 모두 변경하시겠습니까?
|
(으)로 모두 변경하시겠습니까?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정과목 설정 팝업
|
||||||
|
*
|
||||||
|
* - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼
|
||||||
|
* - 검색: 검색 Input, 분류 필터 Select, 건수 표시
|
||||||
|
* - 테이블: 코드 | 계정과목명 | 분류 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||||
|
* - 버튼: 닫기
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
getAccountSubjects,
|
||||||
|
createAccountSubject,
|
||||||
|
updateAccountSubjectStatus,
|
||||||
|
deleteAccountSubject,
|
||||||
|
} from './actions';
|
||||||
|
import type { AccountSubject, AccountSubjectCategory } from './types';
|
||||||
|
import {
|
||||||
|
ACCOUNT_CATEGORY_OPTIONS,
|
||||||
|
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||||
|
ACCOUNT_CATEGORY_LABELS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface AccountSubjectSettingModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSubjectSettingModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AccountSubjectSettingModalProps) {
|
||||||
|
// 추가 폼
|
||||||
|
const [newCode, setNewCode] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newCategory, setNewCategory] = useState<AccountSubjectCategory>('asset');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
// 검색/필터
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadSubjects = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getAccountSubjects({
|
||||||
|
search,
|
||||||
|
category: categoryFilter,
|
||||||
|
});
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setSubjects(result.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('계정과목 목록 조회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [search, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadSubjects();
|
||||||
|
}
|
||||||
|
}, [open, loadSubjects]);
|
||||||
|
|
||||||
|
// 필터링된 목록
|
||||||
|
const filteredSubjects = useMemo(() => {
|
||||||
|
return subjects.filter((s) => {
|
||||||
|
const matchSearch =
|
||||||
|
!search ||
|
||||||
|
s.code.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchCategory = categoryFilter === 'all' || s.category === categoryFilter;
|
||||||
|
return matchSearch && matchCategory;
|
||||||
|
});
|
||||||
|
}, [subjects, search, categoryFilter]);
|
||||||
|
|
||||||
|
// 계정과목 추가
|
||||||
|
const handleAdd = useCallback(async () => {
|
||||||
|
if (!newCode.trim()) {
|
||||||
|
toast.warning('코드를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newName.trim()) {
|
||||||
|
toast.warning('계정과목명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
const result = await createAccountSubject({
|
||||||
|
code: newCode.trim(),
|
||||||
|
name: newName.trim(),
|
||||||
|
category: newCategory,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('계정과목이 추가되었습니다.');
|
||||||
|
setNewCode('');
|
||||||
|
setNewName('');
|
||||||
|
setNewCategory('asset');
|
||||||
|
loadSubjects();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '추가에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('추가 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
}, [newCode, newName, newCategory, loadSubjects]);
|
||||||
|
|
||||||
|
// 상태 토글
|
||||||
|
const handleToggleStatus = useCallback(
|
||||||
|
async (subject: AccountSubject) => {
|
||||||
|
try {
|
||||||
|
const result = await updateAccountSubjectStatus(subject.id, !subject.isActive);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
`${subject.name}이(가) ${!subject.isActive ? '사용중' : '미사용'}으로 변경되었습니다.`
|
||||||
|
);
|
||||||
|
loadSubjects();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('상태 변경 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadSubjects]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
const result = await deleteAccountSubject(deleteTarget.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('계정과목이 삭제되었습니다.');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
loadSubjects();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('삭제 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}, [deleteTarget, loadSubjects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>계정과목 설정</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">계정과목을 추가, 검색, 상태변경, 삭제합니다</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 추가 영역 */}
|
||||||
|
<div className="flex items-end gap-2 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<FormField
|
||||||
|
label="코드"
|
||||||
|
value={newCode}
|
||||||
|
onChange={setNewCode}
|
||||||
|
placeholder="코드"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="계정과목명"
|
||||||
|
value={newName}
|
||||||
|
onChange={setNewName}
|
||||||
|
placeholder="계정과목명"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium mb-1.5 block">분류</label>
|
||||||
|
<Select
|
||||||
|
value={newCategory}
|
||||||
|
onValueChange={(v) => setNewCategory(v as AccountSubjectCategory)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-9" onClick={handleAdd} disabled={isAdding}>
|
||||||
|
{isAdding ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색/필터 영역 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="코드 또는 이름 검색"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="max-w-[250px] h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[100px] h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground ml-auto">
|
||||||
|
{filteredSubjects.length}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">코드</TableHead>
|
||||||
|
<TableHead>계정과목명</TableHead>
|
||||||
|
<TableHead className="text-center w-[80px]">분류</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">상태</TableHead>
|
||||||
|
<TableHead className="text-center w-[60px]">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSubjects.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||||
|
계정과목이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredSubjects.map((subject) => (
|
||||||
|
<TableRow key={subject.id}>
|
||||||
|
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
|
||||||
|
<TableCell className="text-sm">{subject.name}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{ACCOUNT_CATEGORY_LABELS[subject.category]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant={subject.isActive ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleToggleStatus(subject)}
|
||||||
|
>
|
||||||
|
{subject.isActive ? '사용중' : '미사용'}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => setDeleteTarget(subject)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => !v && setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>계정과목 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{deleteTarget?.name}" 계정과목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분개 수정 팝업
|
||||||
|
*
|
||||||
|
* - 거래 정보 (읽기전용): 날짜, 구분, 금액, 적요, 계좌(은행명+계좌번호)
|
||||||
|
* - 전표 적요: 적요 Input
|
||||||
|
* - 분개 내역 테이블: 구분(차변/대변), 계정과목, 거래처, 차변 금액, 대변 금액, 적요, 삭제
|
||||||
|
* - 합계 행 + 대차 균형 표시
|
||||||
|
* - 버튼: 분개 삭제(왼쪽), 취소, 분개 수정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableFooter,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
getJournalDetail,
|
||||||
|
updateJournalDetail,
|
||||||
|
deleteJournalDetail,
|
||||||
|
getAccountSubjects,
|
||||||
|
getVendorList,
|
||||||
|
} from './actions';
|
||||||
|
import type {
|
||||||
|
GeneralJournalRecord,
|
||||||
|
JournalEntryRow,
|
||||||
|
JournalSide,
|
||||||
|
AccountSubject,
|
||||||
|
VendorOption,
|
||||||
|
} from './types';
|
||||||
|
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
|
||||||
|
|
||||||
|
interface JournalEditModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
record: GeneralJournalRecord;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyRow(): JournalEntryRow {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
side: 'debit',
|
||||||
|
accountSubjectId: '',
|
||||||
|
accountSubjectName: '',
|
||||||
|
vendorId: '',
|
||||||
|
vendorName: '',
|
||||||
|
debitAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalEditModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
record,
|
||||||
|
onSuccess,
|
||||||
|
}: JournalEditModalProps) {
|
||||||
|
// 전표 적요
|
||||||
|
const [journalMemo, setJournalMemo] = useState('');
|
||||||
|
|
||||||
|
// 분개 행
|
||||||
|
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// 거래 정보 (읽기전용)
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [accountNumber, setAccountNumber] = useState('');
|
||||||
|
|
||||||
|
// 옵션 데이터
|
||||||
|
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||||
|
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !record) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
|
||||||
|
getJournalDetail(record.id),
|
||||||
|
getAccountSubjects({ category: 'all' }),
|
||||||
|
getVendorList(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (subjectsRes.success && subjectsRes.data) {
|
||||||
|
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||||
|
}
|
||||||
|
if (vendorsRes.success && vendorsRes.data) {
|
||||||
|
setVendors(vendorsRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailRes.success && detailRes.data) {
|
||||||
|
const detail = detailRes.data;
|
||||||
|
setBankName(detail.bank_name || '');
|
||||||
|
setAccountNumber(detail.account_number || '');
|
||||||
|
setJournalMemo(detail.journal_memo || '');
|
||||||
|
|
||||||
|
if (detail.rows && detail.rows.length > 0) {
|
||||||
|
setRows(
|
||||||
|
detail.rows.map((r) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
side: r.side as JournalSide,
|
||||||
|
accountSubjectId: String(r.account_subject_id),
|
||||||
|
accountSubjectName: r.account_subject_name,
|
||||||
|
vendorId: r.vendor_id ? String(r.vendor_id) : '',
|
||||||
|
vendorName: r.vendor_name || '',
|
||||||
|
debitAmount: r.debit_amount,
|
||||||
|
creditAmount: r.credit_amount,
|
||||||
|
memo: r.memo || '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setRows([
|
||||||
|
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
|
||||||
|
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRows([
|
||||||
|
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
|
||||||
|
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRows([
|
||||||
|
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
|
||||||
|
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
// 행 추가
|
||||||
|
const handleAddRow = useCallback(() => {
|
||||||
|
setRows((prev) => [...prev, createEmptyRow()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 삭제
|
||||||
|
const handleRemoveRow = useCallback((rowId: string) => {
|
||||||
|
setRows((prev) => {
|
||||||
|
if (prev.length <= 1) return prev;
|
||||||
|
return prev.filter((r) => r.id !== rowId);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 수정
|
||||||
|
const handleRowChange = useCallback(
|
||||||
|
(rowId: string, field: keyof JournalEntryRow, value: string | number) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => {
|
||||||
|
if (r.id !== rowId) return r;
|
||||||
|
const updated = { ...r, [field]: value };
|
||||||
|
if (field === 'side') {
|
||||||
|
if (value === 'debit') {
|
||||||
|
updated.creditAmount = 0;
|
||||||
|
} else {
|
||||||
|
updated.debitAmount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 합계
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const debitTotal = rows.reduce((sum, r) => sum + (r.debitAmount || 0), 0);
|
||||||
|
const creditTotal = rows.reduce((sum, r) => sum + (r.creditAmount || 0), 0);
|
||||||
|
const isBalanced = debitTotal === creditTotal;
|
||||||
|
const difference = Math.abs(debitTotal - creditTotal);
|
||||||
|
return { debitTotal, creditTotal, isBalanced, difference };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 분개 수정
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const hasEmptyAccount = rows.some((r) => !r.accountSubjectId);
|
||||||
|
if (hasEmptyAccount) {
|
||||||
|
toast.warning('모든 행의 계정과목을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!totals.isBalanced) {
|
||||||
|
toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateJournalDetail(record.id, {
|
||||||
|
journalMemo,
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('분개가 수정되었습니다.');
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '분개 수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [record.id, journalMemo, rows, totals, onSuccess]);
|
||||||
|
|
||||||
|
// 분개 삭제
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
try {
|
||||||
|
const result = await deleteJournalDetail(record.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('분개가 삭제되었습니다.');
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '분개 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}, [record.id, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>분개 수정</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">전표의 분개 내역을 수정합니다</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 거래 정보 (읽기전용) */}
|
||||||
|
<div className="grid grid-cols-5 gap-3 p-3 bg-muted/50 rounded-lg text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">날짜</Label>
|
||||||
|
<div className="font-medium">{record.date}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">구분</Label>
|
||||||
|
<div className="font-medium">
|
||||||
|
{JOURNAL_DIVISION_LABELS[record.division] || record.division}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">금액</Label>
|
||||||
|
<div className="font-medium">{record.amount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">적요</Label>
|
||||||
|
<div className="font-medium truncate">{record.description || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">계좌</Label>
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{bankName && accountNumber ? `${bankName} ${accountNumber}` : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전표 적요 */}
|
||||||
|
<FormField
|
||||||
|
label="전표 적요"
|
||||||
|
value={journalMemo}
|
||||||
|
onChange={setJournalMemo}
|
||||||
|
placeholder="적요 입력"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분개 내역 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">분개 내역</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddRow}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
행 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분개 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[150px] text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-center w-[90px]">구분</TableHead>
|
||||||
|
<TableHead className="w-[150px]">계정과목</TableHead>
|
||||||
|
<TableHead className="w-[140px]">거래처</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">차변 금액</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">대변 금액</TableHead>
|
||||||
|
<TableHead className="w-[120px]">적요</TableHead>
|
||||||
|
<TableHead className="text-center w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<div className="flex">
|
||||||
|
{JOURNAL_SIDE_OPTIONS.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={row.side === opt.value ? 'default' : 'outline'}
|
||||||
|
className="h-7 px-2 text-xs flex-1 rounded-none first:rounded-l-md last:rounded-r-md"
|
||||||
|
onClick={() => handleRowChange(row.id, 'side', opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.accountSubjectId || 'none'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{accountSubjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.vendorId || 'none'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleRowChange(row.id, 'vendorId', v === 'none' ? '' : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<SelectItem key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.debitAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'credit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.creditAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'debit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={row.memo}
|
||||||
|
onChange={(e) => handleRowChange(row.id, 'memo', e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
placeholder="적요"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => handleRemoveRow(row.id)}
|
||||||
|
disabled={rows.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={3} className="text-right text-sm">
|
||||||
|
합계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.debitTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.creditTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} className="text-center text-sm">
|
||||||
|
{totals.isBalanced ? (
|
||||||
|
<span className="text-green-600 font-medium">대차 균형</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-500 font-medium">
|
||||||
|
차이: {totals.difference.toLocaleString()}원
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="mr-auto"
|
||||||
|
>
|
||||||
|
분개 삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? '저장 중...' : '분개 수정'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 분개 삭제 확인 */}
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>분개 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 전표의 분개 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수기 전표 입력 팝업
|
||||||
|
*
|
||||||
|
* - 거래 정보: 전표일자*(필수), 전표번호(자동생성, 읽기전용), 적요 Input
|
||||||
|
* - 분개 내역 테이블: 구분(차변/대변 토글), 계정과목 Select, 거래처 Select, 차변 금액, 대변 금액, 적요, 삭제
|
||||||
|
* - 행 추가 버튼 + 합계 행
|
||||||
|
* - 버튼: 취소, 저장
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableFooter,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
|
||||||
|
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
|
||||||
|
import { JOURNAL_SIDE_OPTIONS } from './types';
|
||||||
|
|
||||||
|
interface ManualJournalEntryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyRow(): JournalEntryRow {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
side: 'debit',
|
||||||
|
accountSubjectId: '',
|
||||||
|
accountSubjectName: '',
|
||||||
|
vendorId: '',
|
||||||
|
vendorName: '',
|
||||||
|
debitAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualJournalEntryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: ManualJournalEntryModalProps) {
|
||||||
|
// 거래 정보
|
||||||
|
const [journalDate, setJournalDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [journalNumber, setJournalNumber] = useState('자동생성');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
// 분개 행
|
||||||
|
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||||
|
|
||||||
|
// 옵션 데이터
|
||||||
|
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||||
|
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 옵션 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
// 초기화
|
||||||
|
setJournalDate(new Date().toISOString().slice(0, 10));
|
||||||
|
setJournalNumber('자동생성');
|
||||||
|
setDescription('');
|
||||||
|
setRows([createEmptyRow()]);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
getAccountSubjects({ category: 'all' }),
|
||||||
|
getVendorList(),
|
||||||
|
]).then(([subjectsRes, vendorsRes]) => {
|
||||||
|
if (subjectsRes.success && subjectsRes.data) {
|
||||||
|
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||||
|
}
|
||||||
|
if (vendorsRes.success && vendorsRes.data) {
|
||||||
|
setVendors(vendorsRes.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 행 추가
|
||||||
|
const handleAddRow = useCallback(() => {
|
||||||
|
setRows((prev) => [...prev, createEmptyRow()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 삭제
|
||||||
|
const handleRemoveRow = useCallback((rowId: string) => {
|
||||||
|
setRows((prev) => {
|
||||||
|
if (prev.length <= 1) return prev;
|
||||||
|
return prev.filter((r) => r.id !== rowId);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 수정
|
||||||
|
const handleRowChange = useCallback(
|
||||||
|
(rowId: string, field: keyof JournalEntryRow, value: string | number) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => {
|
||||||
|
if (r.id !== rowId) return r;
|
||||||
|
const updated = { ...r, [field]: value };
|
||||||
|
// 구분 변경 시 금액 초기화
|
||||||
|
if (field === 'side') {
|
||||||
|
if (value === 'debit') {
|
||||||
|
updated.creditAmount = 0;
|
||||||
|
} else {
|
||||||
|
updated.debitAmount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 합계
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const debitTotal = rows.reduce((sum, r) => sum + (r.debitAmount || 0), 0);
|
||||||
|
const creditTotal = rows.reduce((sum, r) => sum + (r.creditAmount || 0), 0);
|
||||||
|
return { debitTotal, creditTotal };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!journalDate) {
|
||||||
|
toast.warning('전표일자를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEmptyAccount = rows.some((r) => !r.accountSubjectId);
|
||||||
|
if (hasEmptyAccount) {
|
||||||
|
toast.warning('모든 행의 계정과목을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.debitTotal !== totals.creditTotal) {
|
||||||
|
toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.debitTotal === 0) {
|
||||||
|
toast.warning('금액을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await createManualJournal({
|
||||||
|
journalDate,
|
||||||
|
description,
|
||||||
|
rows,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('수기 전표가 등록되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('등록 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [journalDate, description, rows, totals, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>수기 전표 입력</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">수기 전표를 입력하고 분개 내역을 등록합니다</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 거래 정보 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
전표일자 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={journalDate}
|
||||||
|
onChange={setJournalDate}
|
||||||
|
className="mt-1"
|
||||||
|
size="sm"
|
||||||
|
displayFormat="yyyy년 MM월 dd일"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="전표번호"
|
||||||
|
value={journalNumber}
|
||||||
|
onChange={() => {}}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="적요"
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
placeholder="적요 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분개 내역 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">분개 내역</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddRow}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
행 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분개 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-center w-[90px]">구분</TableHead>
|
||||||
|
<TableHead className="w-[150px]">계정과목</TableHead>
|
||||||
|
<TableHead className="w-[140px]">거래처</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">차변 금액</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">대변 금액</TableHead>
|
||||||
|
<TableHead className="w-[120px]">적요</TableHead>
|
||||||
|
<TableHead className="text-center w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<div className="flex">
|
||||||
|
{JOURNAL_SIDE_OPTIONS.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={row.side === opt.value ? 'default' : 'outline'}
|
||||||
|
className="h-7 px-2 text-xs flex-1 rounded-none first:rounded-l-md last:rounded-r-md"
|
||||||
|
onClick={() => handleRowChange(row.id, 'side', opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.accountSubjectId || 'none'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{accountSubjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.vendorId || 'none'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleRowChange(row.id, 'vendorId', v === 'none' ? '' : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택</SelectItem>
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<SelectItem key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.debitAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'credit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.creditAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'debit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={row.memo}
|
||||||
|
onChange={(e) => handleRowChange(row.id, 'memo', e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
placeholder="적요"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => handleRemoveRow(row.id)}
|
||||||
|
disabled={rows.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={3} className="text-right text-sm">
|
||||||
|
합계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.debitTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.creditTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차대변 불일치 경고 */}
|
||||||
|
{totals.debitTotal !== totals.creditTotal && totals.debitTotal > 0 && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
차변 합계({totals.debitTotal.toLocaleString()})와 대변 합계(
|
||||||
|
{totals.creditTotal.toLocaleString()})가 일치하지 않습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'저장'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/components/accounting/GeneralJournalEntry/actions.ts
Normal file
316
src/components/accounting/GeneralJournalEntry/actions.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
import type {
|
||||||
|
GeneralJournalRecord,
|
||||||
|
GeneralJournalApiData,
|
||||||
|
GeneralJournalSummary,
|
||||||
|
GeneralJournalSummaryApiData,
|
||||||
|
AccountSubject,
|
||||||
|
AccountSubjectApiData,
|
||||||
|
JournalEntryRow,
|
||||||
|
VendorOption,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
transformApiToFrontend,
|
||||||
|
transformSummaryApi,
|
||||||
|
transformAccountSubjectApi,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== Mock 데이터 (개발용) =====
|
||||||
|
function generateMockJournalData(): GeneralJournalRecord[] {
|
||||||
|
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
|
||||||
|
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
|
||||||
|
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
|
||||||
|
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const division = divisions[i % 3];
|
||||||
|
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
|
||||||
|
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
|
||||||
|
return {
|
||||||
|
id: String(5000 + i),
|
||||||
|
date: '2025-12-12',
|
||||||
|
division,
|
||||||
|
amount: depositAmount || withdrawalAmount || 50000,
|
||||||
|
description: descriptions[i % 5],
|
||||||
|
journalDescription: journalDescs[i % 5],
|
||||||
|
depositAmount,
|
||||||
|
withdrawalAmount,
|
||||||
|
balance: 1000000 - (i * 50000),
|
||||||
|
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
|
||||||
|
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
|
||||||
|
source: sources[i % 4 === 0 ? 0 : 1],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockSummary(): GeneralJournalSummary {
|
||||||
|
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockAccountSubjects(): AccountSubject[] {
|
||||||
|
return [
|
||||||
|
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
|
||||||
|
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
|
||||||
|
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
|
||||||
|
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
|
||||||
|
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockVendors(): VendorOption[] {
|
||||||
|
return [
|
||||||
|
{ id: '1', name: '삼성전자' },
|
||||||
|
{ id: '2', name: '(주)한국물류' },
|
||||||
|
{ id: '3', name: 'LG전자' },
|
||||||
|
{ id: '4', name: '현대모비스' },
|
||||||
|
{ id: '5', name: '(주)대한상사' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 전표 목록 조회 =====
|
||||||
|
export async function getJournalEntries(params: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
}) {
|
||||||
|
const result = await executePaginatedAction<GeneralJournalApiData, GeneralJournalRecord>({
|
||||||
|
url: buildApiUrl('/api/v1/general-journal-entries', {
|
||||||
|
start_date: params.startDate,
|
||||||
|
end_date: params.endDate,
|
||||||
|
search: params.search || undefined,
|
||||||
|
page: params.page,
|
||||||
|
per_page: params.perPage,
|
||||||
|
}),
|
||||||
|
transform: transformApiToFrontend,
|
||||||
|
errorMessage: '전표 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||||
|
if (!result.success || result.data.length === 0) {
|
||||||
|
const mockData = generateMockJournalData();
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
data: mockData,
|
||||||
|
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 전표 요약 통계 조회 =====
|
||||||
|
export async function getJournalSummary(params: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<ActionResult<GeneralJournalSummary>> {
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/general-journal-entries/summary', {
|
||||||
|
start_date: params.startDate,
|
||||||
|
end_date: params.endDate,
|
||||||
|
search: params.search || undefined,
|
||||||
|
}),
|
||||||
|
transform: (data: GeneralJournalSummaryApiData) => transformSummaryApi(data),
|
||||||
|
errorMessage: '전표 요약 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return { success: true, data: generateMockSummary() };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수기 전표 등록 =====
|
||||||
|
export async function createManualJournal(data: {
|
||||||
|
journalDate: string;
|
||||||
|
description: string;
|
||||||
|
rows: JournalEntryRow[];
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/general-journal-entries'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
journal_date: data.journalDate,
|
||||||
|
description: data.description,
|
||||||
|
rows: data.rows.map((r) => ({
|
||||||
|
side: r.side,
|
||||||
|
account_subject_id: r.accountSubjectId,
|
||||||
|
vendor_id: r.vendorId || null,
|
||||||
|
debit_amount: r.debitAmount,
|
||||||
|
credit_amount: r.creditAmount,
|
||||||
|
memo: r.memo || null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
errorMessage: '수기 전표 등록에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 목록 조회 =====
|
||||||
|
export async function getAccountSubjects(params?: {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
}): Promise<ActionResult<AccountSubject[]>> {
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/account-subjects', {
|
||||||
|
search: params?.search || undefined,
|
||||||
|
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||||
|
}),
|
||||||
|
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
|
||||||
|
errorMessage: '계정과목 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||||
|
if (!result.success || !result.data || result.data.length === 0) {
|
||||||
|
return { success: true, data: generateMockAccountSubjects() };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 추가 =====
|
||||||
|
export async function createAccountSubject(data: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/account-subjects'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
category: data.category,
|
||||||
|
},
|
||||||
|
errorMessage: '계정과목 추가에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 상태 토글 =====
|
||||||
|
export async function updateAccountSubjectStatus(
|
||||||
|
id: string,
|
||||||
|
isActive: boolean
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { is_active: isActive },
|
||||||
|
errorMessage: '계정과목 상태 변경에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 삭제 =====
|
||||||
|
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '계정과목 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 상세 조회 =====
|
||||||
|
type JournalDetailData = {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
division: string;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
bank_name: string;
|
||||||
|
account_number: string;
|
||||||
|
journal_memo: string;
|
||||||
|
rows: {
|
||||||
|
id: number;
|
||||||
|
side: string;
|
||||||
|
account_subject_id: number;
|
||||||
|
account_subject_name: string;
|
||||||
|
vendor_id: number | null;
|
||||||
|
vendor_name: string;
|
||||||
|
debit_amount: number;
|
||||||
|
credit_amount: number;
|
||||||
|
memo: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getJournalDetail(id: string): Promise<ActionResult<JournalDetailData>> {
|
||||||
|
const result = await executeServerAction<JournalDetailData>({
|
||||||
|
url: buildApiUrl(`/api/v1/general-journal-entries/${id}`),
|
||||||
|
errorMessage: '분개 상세 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 실패 시 mock fallback (개발용)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: Number(id),
|
||||||
|
date: '2025-12-12',
|
||||||
|
division: 'deposit',
|
||||||
|
amount: 100000,
|
||||||
|
description: '사무용품 구매',
|
||||||
|
bank_name: '신한은행',
|
||||||
|
account_number: '110-123-456789',
|
||||||
|
journal_memo: '',
|
||||||
|
rows: [
|
||||||
|
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
|
||||||
|
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 수정 =====
|
||||||
|
export async function updateJournalDetail(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
journalMemo: string;
|
||||||
|
rows: JournalEntryRow[];
|
||||||
|
}
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/general-journal-entries/${id}/journal`),
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
journal_memo: data.journalMemo,
|
||||||
|
rows: data.rows.map((r) => ({
|
||||||
|
side: r.side,
|
||||||
|
account_subject_id: r.accountSubjectId,
|
||||||
|
vendor_id: r.vendorId || null,
|
||||||
|
debit_amount: r.debitAmount,
|
||||||
|
credit_amount: r.creditAmount,
|
||||||
|
memo: r.memo || null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
errorMessage: '분개 수정에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 삭제 =====
|
||||||
|
export async function deleteJournalDetail(id: string): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/general-journal-entries/${id}/journal`),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '분개 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래처 목록 조회 =====
|
||||||
|
export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/vendors', { per_page: 9999 }),
|
||||||
|
transform: (data: { id: number; name: string }[]) =>
|
||||||
|
data.map((v) => ({ id: String(v.id), name: v.name })),
|
||||||
|
errorMessage: '거래처 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||||
|
if (!result.success || !result.data || result.data.length === 0) {
|
||||||
|
return { success: true, data: generateMockVendors() };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
423
src/components/accounting/GeneralJournalEntry/index.tsx
Normal file
423
src/components/accounting/GeneralJournalEntry/index.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일반전표입력 - 메인 리스트 페이지
|
||||||
|
*
|
||||||
|
* 기획서 기준:
|
||||||
|
* - 날짜범위 + 기간버튼(이번달, 1년전, D-2일~D-5일) + 검색 Input
|
||||||
|
* - 요약: 4개 통계값 (전입 건수/금액, 전출 건수/금액)
|
||||||
|
* - 버튼: 계정과목 설정, 수기 전표 입력
|
||||||
|
* - 테이블 10컬럼: 날짜, 적요, 입금, 출금, 잔액, 분개, 내역, 차변, 대변, 분개(버튼)
|
||||||
|
* - tableFooter: 합계 행 + 범례(수기/연동)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { FileText, Settings, PenLine, Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
UniversalListPage,
|
||||||
|
type UniversalListConfig,
|
||||||
|
type SelectionHandlers,
|
||||||
|
type RowClickHandlers,
|
||||||
|
type StatCard,
|
||||||
|
} from '@/components/templates/UniversalListPage';
|
||||||
|
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||||
|
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||||
|
import { getJournalEntries, getJournalSummary } from './actions';
|
||||||
|
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
|
||||||
|
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
|
||||||
|
import { JournalEditModal } from './JournalEditModal';
|
||||||
|
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
|
||||||
|
import {
|
||||||
|
PERIOD_BUTTONS,
|
||||||
|
JOURNAL_DIVISION_LABELS,
|
||||||
|
getPeriodDates,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 테이블 컬럼 (기획서 기준 10개) =====
|
||||||
|
const tableColumns = [
|
||||||
|
{ key: 'date', label: '날짜', className: 'text-center w-[100px]' },
|
||||||
|
{ key: 'description', label: '적요', className: 'w-[140px]' },
|
||||||
|
{ key: 'depositAmount', label: '입금', className: 'text-right w-[100px]' },
|
||||||
|
{ key: 'withdrawalAmount', label: '출금', className: 'text-right w-[100px]' },
|
||||||
|
{ key: 'balance', label: '잔액', className: 'text-right w-[100px]' },
|
||||||
|
{ key: 'division', label: '구분', className: 'text-center w-[70px]' },
|
||||||
|
{ key: 'journalDescription', label: '내역', className: 'w-[100px]' },
|
||||||
|
{ key: 'debitAmount', label: '차변', className: 'text-right w-[90px]' },
|
||||||
|
{ key: 'creditAmount', label: '대변', className: 'text-right w-[90px]' },
|
||||||
|
{ key: 'journalAction', label: '분개', className: 'text-center w-[70px]', sortable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GeneralJournalEntry() {
|
||||||
|
// ===== 필터 상태 =====
|
||||||
|
const defaultPeriod = getPeriodDates('this_month');
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<PeriodButtonValue>('this_month');
|
||||||
|
const [startDate, setStartDate] = useState(defaultPeriod.start);
|
||||||
|
const [endDate, setEndDate] = useState(defaultPeriod.end);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
|
||||||
|
// ===== 데이터 상태 =====
|
||||||
|
const [journalData, setJournalData] = useState<GeneralJournalRecord[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const isInitialLoadDone = useRef(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
perPage: 20,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 요약 상태 =====
|
||||||
|
const [summary, setSummary] = useState<GeneralJournalSummary>({
|
||||||
|
totalCount: 0,
|
||||||
|
depositCount: 0,
|
||||||
|
depositAmount: 0,
|
||||||
|
withdrawalCount: 0,
|
||||||
|
withdrawalAmount: 0,
|
||||||
|
journalCompleteCount: 0,
|
||||||
|
journalIncompleteCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 모달 상태 =====
|
||||||
|
const [showAccountSetting, setShowAccountSetting] = useState(false);
|
||||||
|
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||||
|
const [journalEditTarget, setJournalEditTarget] = useState<GeneralJournalRecord | null>(null);
|
||||||
|
|
||||||
|
// ===== 기간 버튼 클릭 =====
|
||||||
|
const handlePeriodClick = useCallback((period: PeriodButtonValue) => {
|
||||||
|
setSelectedPeriod(period);
|
||||||
|
const dates = getPeriodDates(period);
|
||||||
|
setStartDate(dates.start);
|
||||||
|
setEndDate(dates.end);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ===== 데이터 로드 =====
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!isInitialLoadDone.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [listResult, summaryResult] = await Promise.all([
|
||||||
|
getJournalEntries({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search: searchKeyword,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: 20,
|
||||||
|
}),
|
||||||
|
getJournalSummary({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search: searchKeyword,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success) {
|
||||||
|
setJournalData(listResult.data);
|
||||||
|
setPagination(listResult.pagination);
|
||||||
|
} else {
|
||||||
|
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryResult.success && summaryResult.data) {
|
||||||
|
setSummary(summaryResult.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
isInitialLoadDone.current = true;
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, searchKeyword, currentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 조회 버튼 =====
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 수기 등록 완료 =====
|
||||||
|
const handleManualEntrySuccess = useCallback(() => {
|
||||||
|
setShowManualEntry(false);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 분개 수정 완료 =====
|
||||||
|
const handleJournalEditSuccess = useCallback(() => {
|
||||||
|
setJournalEditTarget(null);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 합계 계산 =====
|
||||||
|
const tableTotals = useMemo(() => ({
|
||||||
|
depositTotal: journalData.reduce((sum, r) => sum + (r.depositAmount || 0), 0),
|
||||||
|
withdrawalTotal: journalData.reduce((sum, r) => sum + (r.withdrawalAmount || 0), 0),
|
||||||
|
debitTotal: journalData.reduce((sum, r) => sum + (r.debitAmount || 0), 0),
|
||||||
|
creditTotal: journalData.reduce((sum, r) => sum + (r.creditAmount || 0), 0),
|
||||||
|
}), [journalData]);
|
||||||
|
|
||||||
|
// ===== UniversalListPage Config =====
|
||||||
|
const config: UniversalListConfig<GeneralJournalRecord> = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: '일반전표입력',
|
||||||
|
description: '일반 전표를 입력하고 관리합니다',
|
||||||
|
icon: FileText,
|
||||||
|
basePath: '/accounting/general-journal-entry',
|
||||||
|
|
||||||
|
idField: 'id',
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
getList: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: journalData,
|
||||||
|
totalCount: pagination.total,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: tableColumns,
|
||||||
|
clientSideFiltering: false,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
hideSearch: true,
|
||||||
|
showCheckbox: false,
|
||||||
|
|
||||||
|
// ===== 날짜 선택기 + 기간 버튼 + 검색 =====
|
||||||
|
dateRangeSelector: {
|
||||||
|
enabled: true,
|
||||||
|
showPresets: false,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange: setStartDate,
|
||||||
|
onEndDateChange: setEndDate,
|
||||||
|
extraActions: (
|
||||||
|
<>
|
||||||
|
<ScrollableButtonGroup>
|
||||||
|
{PERIOD_BUTTONS.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.value}
|
||||||
|
size="sm"
|
||||||
|
variant={selectedPeriod === p.value ? 'default' : 'outline'}
|
||||||
|
className="h-8 px-3 text-xs shrink-0"
|
||||||
|
onClick={() => handlePeriodClick(p.value)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ScrollableButtonGroup>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="max-w-[200px] h-9 text-sm pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 통계 카드 5개 =====
|
||||||
|
computeStats: (): StatCard[] => [
|
||||||
|
{ label: '전체', value: `${summary.totalCount}건`, icon: FileText, iconColor: 'text-gray-500' },
|
||||||
|
{ label: '입금', value: `${summary.depositAmount.toLocaleString()}원`, icon: FileText, iconColor: 'text-blue-500' },
|
||||||
|
{ label: '출금', value: `${summary.withdrawalAmount.toLocaleString()}원`, icon: FileText, iconColor: 'text-red-500' },
|
||||||
|
{ label: '분개완료', value: `${summary.journalCompleteCount}건`, icon: FileText, iconColor: 'text-green-500' },
|
||||||
|
{ label: '미분개', value: `${summary.journalIncompleteCount}건`, icon: FileText, iconColor: 'text-orange-500' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// ===== 헤더 액션 =====
|
||||||
|
headerActions: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAccountSetting(true)}>
|
||||||
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
|
계정과목 설정
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setShowManualEntry(true)}>
|
||||||
|
<PenLine className="h-4 w-4 mr-1" />
|
||||||
|
수기 전표 입력
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 (기획서 기준 10컬럼) =====
|
||||||
|
renderTableRow: (
|
||||||
|
item: GeneralJournalRecord,
|
||||||
|
_index: number,
|
||||||
|
_globalIndex: number,
|
||||||
|
_handlers: SelectionHandlers & RowClickHandlers<GeneralJournalRecord>
|
||||||
|
) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="text-center text-sm w-[100px]">{item.date}</TableCell>
|
||||||
|
<TableCell className="text-sm w-[140px]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${item.source === 'manual' ? 'bg-orange-400' : 'bg-blue-400'}`} />
|
||||||
|
{item.description || '-'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-blue-600 w-[100px]">
|
||||||
|
{item.depositAmount ? item.depositAmount.toLocaleString() : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm text-red-600 w-[100px]">
|
||||||
|
{item.withdrawalAmount ? item.withdrawalAmount.toLocaleString() : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm w-[100px]">{item.balance.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm w-[70px]">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{JOURNAL_DIVISION_LABELS[item.division] || item.division}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm w-[100px]">{item.journalDescription || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm w-[90px]">
|
||||||
|
{item.debitAmount ? item.debitAmount.toLocaleString() : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm w-[90px]">
|
||||||
|
{item.creditAmount ? item.creditAmount.toLocaleString() : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center w-[70px]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setJournalEditTarget(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
분개
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
renderMobileCard: (
|
||||||
|
item: GeneralJournalRecord,
|
||||||
|
_index: number,
|
||||||
|
_globalIndex: number,
|
||||||
|
_handlers: SelectionHandlers & RowClickHandlers<GeneralJournalRecord>
|
||||||
|
) => (
|
||||||
|
<MobileCard
|
||||||
|
key={item.id}
|
||||||
|
title={item.description || '전표'}
|
||||||
|
subtitle={item.date}
|
||||||
|
badge={JOURNAL_DIVISION_LABELS[item.division] || item.division}
|
||||||
|
badgeVariant="outline"
|
||||||
|
isSelected={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClick={() => setJournalEditTarget(item)}
|
||||||
|
details={[
|
||||||
|
{ label: '입금', value: `${(item.depositAmount || 0).toLocaleString()}원` },
|
||||||
|
{ label: '출금', value: `${(item.withdrawalAmount || 0).toLocaleString()}원` },
|
||||||
|
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||||
|
{ label: '차변', value: `${(item.debitAmount || 0).toLocaleString()}원` },
|
||||||
|
{ label: '대변', value: `${(item.creditAmount || 0).toLocaleString()}원` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 합계 행 + 범례 (테이블 안 tableFooter) =====
|
||||||
|
tableFooter: (
|
||||||
|
<>
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={2} className="text-right font-bold">합계</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-blue-600">
|
||||||
|
{tableTotals.depositTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-red-600">
|
||||||
|
{tableTotals.withdrawalTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
<TableCell />
|
||||||
|
<TableCell />
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{tableTotals.debitTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{tableTotals.creditTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="py-2">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-orange-400" />
|
||||||
|
수기 전표
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400" />
|
||||||
|
연동 전표
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
journalData,
|
||||||
|
pagination,
|
||||||
|
summary,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedPeriod,
|
||||||
|
searchKeyword,
|
||||||
|
tableTotals,
|
||||||
|
handlePeriodClick,
|
||||||
|
handleSearch,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UniversalListPage
|
||||||
|
config={config}
|
||||||
|
initialData={journalData}
|
||||||
|
externalPagination={{
|
||||||
|
currentPage: pagination.currentPage,
|
||||||
|
totalPages: pagination.lastPage,
|
||||||
|
totalItems: pagination.total,
|
||||||
|
itemsPerPage: pagination.perPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
externalIsLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 계정과목 설정 팝업 */}
|
||||||
|
<AccountSubjectSettingModal
|
||||||
|
open={showAccountSetting}
|
||||||
|
onOpenChange={setShowAccountSetting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 수기 전표 입력 팝업 */}
|
||||||
|
<ManualJournalEntryModal
|
||||||
|
open={showManualEntry}
|
||||||
|
onOpenChange={setShowManualEntry}
|
||||||
|
onSuccess={handleManualEntrySuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분개 수정 팝업 */}
|
||||||
|
{journalEditTarget && (
|
||||||
|
<JournalEditModal
|
||||||
|
open={!!journalEditTarget}
|
||||||
|
onOpenChange={(open: boolean) => !open && setJournalEditTarget(null)}
|
||||||
|
record={journalEditTarget}
|
||||||
|
onSuccess={handleJournalEditSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/components/accounting/GeneralJournalEntry/types.ts
Normal file
266
src/components/accounting/GeneralJournalEntry/types.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 일반전표입력 - 타입 및 상수 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 전표 구분 =====
|
||||||
|
export type JournalDivision = 'deposit' | 'withdrawal' | 'transfer' | 'manual';
|
||||||
|
|
||||||
|
export const JOURNAL_DIVISION_LABELS: Record<JournalDivision, string> = {
|
||||||
|
deposit: '입금',
|
||||||
|
withdrawal: '출금',
|
||||||
|
transfer: '대체',
|
||||||
|
manual: '수기',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 전표 소스 (수기/연동) =====
|
||||||
|
export type JournalSource = 'manual' | 'linked';
|
||||||
|
|
||||||
|
export const JOURNAL_SOURCE_MAP: Record<JournalSource, { label: string; color: string }> = {
|
||||||
|
manual: { label: '수기 전표', color: 'bg-orange-400' },
|
||||||
|
linked: { label: '연동 전표', color: 'bg-blue-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 기간 버튼 =====
|
||||||
|
export const PERIOD_BUTTONS = [
|
||||||
|
{ value: 'this_month', label: '이번달' },
|
||||||
|
{ value: 'last_month', label: '지난달' },
|
||||||
|
{ value: 'd2', label: 'D-2일' },
|
||||||
|
{ value: 'd3', label: 'D-3일' },
|
||||||
|
{ value: 'd4', label: 'D-4일' },
|
||||||
|
{ value: 'd5', label: 'D-5일' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
|
||||||
|
|
||||||
|
// ===== 계정과목 분류 =====
|
||||||
|
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
|
||||||
|
{ value: 'asset', label: '자산' },
|
||||||
|
{ value: 'liability', label: '부채' },
|
||||||
|
{ value: 'capital', label: '자본' },
|
||||||
|
{ value: 'revenue', label: '수익' },
|
||||||
|
{ value: 'expense', label: '비용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...ACCOUNT_CATEGORY_OPTIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
|
||||||
|
asset: '자산',
|
||||||
|
liability: '부채',
|
||||||
|
capital: '자본',
|
||||||
|
revenue: '수익',
|
||||||
|
expense: '비용',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 분개 구분 (차변/대변) =====
|
||||||
|
export type JournalSide = 'debit' | 'credit';
|
||||||
|
|
||||||
|
export const JOURNAL_SIDE_OPTIONS: { value: JournalSide; label: string }[] = [
|
||||||
|
{ value: 'debit', label: '차변' },
|
||||||
|
{ value: 'credit', label: '대변' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 메인 리스트 레코드 (프론트엔드 camelCase) =====
|
||||||
|
export interface GeneralJournalRecord {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
division: JournalDivision;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
journalDescription: string;
|
||||||
|
depositAmount: number;
|
||||||
|
withdrawalAmount: number;
|
||||||
|
balance: number;
|
||||||
|
debitAmount: number;
|
||||||
|
creditAmount: number;
|
||||||
|
source: JournalSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== API 응답 (snake_case) =====
|
||||||
|
export interface GeneralJournalApiData {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
division: string;
|
||||||
|
amount: string | number;
|
||||||
|
description: string;
|
||||||
|
journal_description: string;
|
||||||
|
deposit_amount?: string | number;
|
||||||
|
withdrawal_amount?: string | number;
|
||||||
|
balance?: string | number;
|
||||||
|
debit_amount: string | number;
|
||||||
|
credit_amount: string | number;
|
||||||
|
source: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 요약 통계 =====
|
||||||
|
export interface GeneralJournalSummary {
|
||||||
|
totalCount: number;
|
||||||
|
depositCount: number;
|
||||||
|
depositAmount: number;
|
||||||
|
withdrawalCount: number;
|
||||||
|
withdrawalAmount: number;
|
||||||
|
journalCompleteCount: number;
|
||||||
|
journalIncompleteCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneralJournalSummaryApiData {
|
||||||
|
total_count?: number;
|
||||||
|
deposit_count: number;
|
||||||
|
deposit_amount: number;
|
||||||
|
withdrawal_count: number;
|
||||||
|
withdrawal_amount: number;
|
||||||
|
journal_complete_count?: number;
|
||||||
|
journal_incomplete_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 =====
|
||||||
|
export interface AccountSubject {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: AccountSubjectCategory;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSubjectApiData {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
is_active: boolean | number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 행 =====
|
||||||
|
export interface JournalEntryRow {
|
||||||
|
id: string;
|
||||||
|
side: JournalSide;
|
||||||
|
accountSubjectId: string;
|
||||||
|
accountSubjectName: string;
|
||||||
|
vendorId: string;
|
||||||
|
vendorName: string;
|
||||||
|
debitAmount: number;
|
||||||
|
creditAmount: number;
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수기 전표 입력 폼 =====
|
||||||
|
export interface ManualJournalFormData {
|
||||||
|
journalDate: string;
|
||||||
|
journalNumber: string;
|
||||||
|
description: string;
|
||||||
|
rows: JournalEntryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 수정용 거래 정보 (읽기전용) =====
|
||||||
|
export interface JournalEditData {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
division: JournalDivision;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래처 옵션 =====
|
||||||
|
export interface VendorOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== API → Frontend 변환 =====
|
||||||
|
export function transformApiToFrontend(apiData: GeneralJournalApiData): GeneralJournalRecord {
|
||||||
|
const amount = Number(apiData.amount);
|
||||||
|
const division = apiData.division as JournalDivision;
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
date: apiData.date,
|
||||||
|
division,
|
||||||
|
amount,
|
||||||
|
description: apiData.description || '',
|
||||||
|
journalDescription: apiData.journal_description || '',
|
||||||
|
depositAmount: apiData.deposit_amount != null ? Number(apiData.deposit_amount) : (division === 'deposit' ? amount : 0),
|
||||||
|
withdrawalAmount: apiData.withdrawal_amount != null ? Number(apiData.withdrawal_amount) : (division === 'withdrawal' ? amount : 0),
|
||||||
|
balance: apiData.balance != null ? Number(apiData.balance) : 0,
|
||||||
|
debitAmount: Number(apiData.debit_amount),
|
||||||
|
creditAmount: Number(apiData.credit_amount),
|
||||||
|
source: apiData.source as JournalSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 요약 API → Frontend 변환 =====
|
||||||
|
export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): GeneralJournalSummary {
|
||||||
|
const depositCount = apiData.deposit_count;
|
||||||
|
const withdrawalCount = apiData.withdrawal_count;
|
||||||
|
const totalCount = apiData.total_count ?? (depositCount + withdrawalCount);
|
||||||
|
const journalCompleteCount = apiData.journal_complete_count ?? 0;
|
||||||
|
const journalIncompleteCount = apiData.journal_incomplete_count ?? (totalCount - journalCompleteCount);
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
depositCount,
|
||||||
|
depositAmount: apiData.deposit_amount,
|
||||||
|
withdrawalCount,
|
||||||
|
withdrawalAmount: apiData.withdrawal_amount,
|
||||||
|
journalCompleteCount,
|
||||||
|
journalIncompleteCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 API → Frontend 변환 =====
|
||||||
|
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
code: apiData.code,
|
||||||
|
name: apiData.name,
|
||||||
|
category: apiData.category as AccountSubjectCategory,
|
||||||
|
isActive: Boolean(apiData.is_active),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 기간 버튼 → 날짜 변환 =====
|
||||||
|
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
|
||||||
|
const today = new Date();
|
||||||
|
const formatDate = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'this_month': {
|
||||||
|
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return { start: formatDate(start), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'last_month': {
|
||||||
|
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||||
|
const end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||||
|
return { start: formatDate(start), end: formatDate(end) };
|
||||||
|
}
|
||||||
|
case 'd2': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 2);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'd3': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 3);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'd4': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 4);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'd5': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 5);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { start: formatDate(today), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import { PageLayout, PageHeader } from '@/components/organisms';
|
||||||
|
import { Gift, AlertCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
createGiftCertificate,
|
||||||
|
updateGiftCertificate,
|
||||||
|
deleteGiftCertificate,
|
||||||
|
} from './actions';
|
||||||
|
import {
|
||||||
|
PURCHASE_PURPOSE_OPTIONS,
|
||||||
|
ENTERTAINMENT_EXPENSE_OPTIONS,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
FACE_VALUE_THRESHOLD,
|
||||||
|
createEmptyFormData,
|
||||||
|
} from './types';
|
||||||
|
import type { GiftCertificateFormData } from './types';
|
||||||
|
|
||||||
|
interface GiftCertificateDetailProps {
|
||||||
|
mode: 'new' | 'edit';
|
||||||
|
initialData?: GiftCertificateFormData;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GiftCertificateDetail({
|
||||||
|
mode,
|
||||||
|
initialData,
|
||||||
|
id,
|
||||||
|
}: GiftCertificateDetailProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [formData, setFormData] = useState<GiftCertificateFormData>(
|
||||||
|
initialData ?? createEmptyFormData()
|
||||||
|
);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isNew = mode === 'new';
|
||||||
|
const showUsageInfo = formData.faceValue >= FACE_VALUE_THRESHOLD;
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof GiftCertificateFormData, value: string | number) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error('상품권명을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.faceValue || formData.faceValue <= 0) {
|
||||||
|
toast.error('액면가를 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.purchaseDate) {
|
||||||
|
toast.error('구입일을 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 50만원 이상 시 상품권 정보 필수 체크
|
||||||
|
if (showUsageInfo && formData.status === 'used') {
|
||||||
|
if (!formData.recipientName.trim()) {
|
||||||
|
toast.error('수령인을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = isNew
|
||||||
|
? await createGiftCertificate(formData)
|
||||||
|
: await updateGiftCertificate(id!, formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
|
||||||
|
router.push('/ko/accounting/gift-certificates');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, isNew, id, showUsageInfo, router]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteGiftCertificate(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('상품권이 삭제되었습니다.');
|
||||||
|
router.push('/ko/accounting/gift-certificates');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
// 상태 옵션 (등록 시 '전체' 제외)
|
||||||
|
const statusDetailOptions = STATUS_OPTIONS.filter((opt) => opt.value !== 'all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={isNew ? '상품권 등록' : '상품권 상세'}
|
||||||
|
description={
|
||||||
|
isNew
|
||||||
|
? '새 상품권을 등록합니다.'
|
||||||
|
: '상품권 상세를 등록하고 관리합니다.'
|
||||||
|
}
|
||||||
|
icon={Gift}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="일련번호"
|
||||||
|
value={formData.serialNumber}
|
||||||
|
onChange={(v) => handleChange('serialNumber', v)}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
disabled={!isNew}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="상품권명"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(v) => handleChange('name', v)}
|
||||||
|
placeholder="상품권명을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="액면가"
|
||||||
|
required
|
||||||
|
type="currency"
|
||||||
|
value={formData.faceValue}
|
||||||
|
onChangeNumber={(v) => handleChange('faceValue', v ?? 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="구입처"
|
||||||
|
type="select"
|
||||||
|
value={formData.vendorId}
|
||||||
|
onChange={(v) => handleChange('vendorId', v)}
|
||||||
|
options={[]}
|
||||||
|
selectPlaceholder="거래처 선택"
|
||||||
|
helpText="매입 거래처명 목록 (API 연동 후 사용 가능)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="구입일"
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={formData.purchaseDate}
|
||||||
|
onChange={(v) => handleChange('purchaseDate', v)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="구입목적"
|
||||||
|
type="select"
|
||||||
|
value={formData.purchasePurpose}
|
||||||
|
onChange={(v) => handleChange('purchasePurpose', v)}
|
||||||
|
options={PURCHASE_PURPOSE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="접대비"
|
||||||
|
type="select"
|
||||||
|
value={formData.entertainmentExpense}
|
||||||
|
onChange={(v) => handleChange('entertainmentExpense', v)}
|
||||||
|
options={ENTERTAINMENT_EXPENSE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="상태"
|
||||||
|
type="select"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(v) => handleChange('status', v)}
|
||||||
|
options={statusDetailOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 상품권 정보 (액면가 50만원 이상 시) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
상품권 정보
|
||||||
|
{showUsageInfo && <span className="text-red-500">*</span>}
|
||||||
|
</CardTitle>
|
||||||
|
{showUsageInfo ? (
|
||||||
|
<p className="text-sm text-red-500 flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
액면가 50만원 이상일 경우 입력 필수입니다
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
액면가 50만원 이상일 경우 입력이 필요합니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="사용일"
|
||||||
|
type="date"
|
||||||
|
value={formData.usedDate}
|
||||||
|
onChange={(v) => handleChange('usedDate', v)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="수령인"
|
||||||
|
value={formData.recipientName}
|
||||||
|
onChange={(v) => handleChange('recipientName', v)}
|
||||||
|
placeholder="수령인 이름"
|
||||||
|
required={showUsageInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="수령인 소속"
|
||||||
|
value={formData.recipientOrganization}
|
||||||
|
onChange={(v) => handleChange('recipientOrganization', v)}
|
||||||
|
placeholder="회사명"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="사용처/용도"
|
||||||
|
value={formData.usageDescription}
|
||||||
|
onChange={(v) => handleChange('usageDescription', v)}
|
||||||
|
placeholder="내용"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
type="textarea"
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(v) => handleChange('memo', v)}
|
||||||
|
placeholder="비고 사항을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{!isNew && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/ko/accounting/gift-certificates')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? '처리 중...' : isNew ? '등록' : '수정'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/accounting/GiftCertificateManagement/actions.ts
Normal file
155
src/components/accounting/GiftCertificateManagement/actions.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 상품권 관리 서버 액션 (Mock)
|
||||||
|
*
|
||||||
|
* API Endpoints (예정):
|
||||||
|
* - GET /api/v1/gift-certificates - 목록 조회
|
||||||
|
* - GET /api/v1/gift-certificates/{id} - 상세 조회
|
||||||
|
* - POST /api/v1/gift-certificates - 등록
|
||||||
|
* - PUT /api/v1/gift-certificates/{id} - 수정
|
||||||
|
* - DELETE /api/v1/gift-certificates/{id} - 삭제
|
||||||
|
* - GET /api/v1/gift-certificates/summary - 요약 통계
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import type { ActionResult } from '@/lib/api/execute-server-action';
|
||||||
|
// import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
|
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
|
// import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
import type {
|
||||||
|
GiftCertificateRecord,
|
||||||
|
GiftCertificateFormData,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 상품권 목록 조회 (Mock) =====
|
||||||
|
export async function getGiftCertificates(_params?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<ActionResult<GiftCertificateRecord[]>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
|
||||||
|
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상품권 상세 조회 (Mock) =====
|
||||||
|
export async function getGiftCertificateById(
|
||||||
|
_id: string
|
||||||
|
): Promise<ActionResult<GiftCertificateFormData>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||||
|
// transform: transformDetailApiToFrontend,
|
||||||
|
// errorMessage: '상품권 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: false, error: '상품권을 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상품권 등록 (Mock) =====
|
||||||
|
export async function createGiftCertificate(
|
||||||
|
_data: GiftCertificateFormData
|
||||||
|
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl('/api/v1/gift-certificates'),
|
||||||
|
// method: 'POST',
|
||||||
|
// body: transformFrontendToApi(data),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '상품권 등록에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
|
||||||
|
name: _data.name,
|
||||||
|
faceValue: _data.faceValue,
|
||||||
|
purchaseDate: _data.purchaseDate,
|
||||||
|
usedDate: _data.usedDate || null,
|
||||||
|
status: _data.status,
|
||||||
|
entertainmentExpense: _data.entertainmentExpense,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상품권 수정 (Mock) =====
|
||||||
|
export async function updateGiftCertificate(
|
||||||
|
_id: string,
|
||||||
|
_data: GiftCertificateFormData
|
||||||
|
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||||
|
// method: 'PUT',
|
||||||
|
// body: transformFrontendToApi(data),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '상품권 수정에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: _id,
|
||||||
|
serialNumber: _data.serialNumber,
|
||||||
|
name: _data.name,
|
||||||
|
faceValue: _data.faceValue,
|
||||||
|
purchaseDate: _data.purchaseDate,
|
||||||
|
usedDate: _data.usedDate || null,
|
||||||
|
status: _data.status,
|
||||||
|
entertainmentExpense: _data.entertainmentExpense,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상품권 삭제 (Mock) =====
|
||||||
|
export async function deleteGiftCertificate(
|
||||||
|
_id: string
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||||
|
// method: 'DELETE',
|
||||||
|
// errorMessage: '상품권 삭제에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상품권 요약 통계 (Mock) =====
|
||||||
|
export async function getGiftCertificateSummary(_params?: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}): Promise<ActionResult<{
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
holdingCount: number;
|
||||||
|
holdingAmount: number;
|
||||||
|
usedCount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
entertainmentCount: number;
|
||||||
|
entertainmentAmount: number;
|
||||||
|
}>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
|
||||||
|
// transform: transformSummary,
|
||||||
|
// errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
holdingCount: 0,
|
||||||
|
holdingAmount: 0,
|
||||||
|
usedCount: 0,
|
||||||
|
usedAmount: 0,
|
||||||
|
entertainmentCount: 0,
|
||||||
|
entertainmentAmount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
378
src/components/accounting/GiftCertificateManagement/index.tsx
Normal file
378
src/components/accounting/GiftCertificateManagement/index.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품권 관리
|
||||||
|
*
|
||||||
|
* 기획서 기준:
|
||||||
|
* - 통계 4개: 전체 상품권, 보유 상품권, 사용 상품권, 접대비 해당
|
||||||
|
* - 테이블: 체크박스, 번호, 일련번호, 상품권명, 액면가, 구입일, 사용일, 접대비, 상태
|
||||||
|
* - 필터: 기간(프리셋), 상태, 접대비
|
||||||
|
* - 액션: 상품권 등록
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Gift, PlusCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||||
|
import {
|
||||||
|
UniversalListPage,
|
||||||
|
type UniversalListConfig,
|
||||||
|
type SelectionHandlers,
|
||||||
|
type RowClickHandlers,
|
||||||
|
type StatCard,
|
||||||
|
} from '@/components/templates/UniversalListPage';
|
||||||
|
import {
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
STATUS_MAP,
|
||||||
|
ENTERTAINMENT_FILTER_OPTIONS,
|
||||||
|
} from './types';
|
||||||
|
import type {
|
||||||
|
GiftCertificateRecord,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
getGiftCertificates,
|
||||||
|
getGiftCertificateSummary,
|
||||||
|
} from './actions';
|
||||||
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
|
|
||||||
|
// ===== 테이블 컬럼 정의 (체크박스/No. 제외) =====
|
||||||
|
const tableColumns = [
|
||||||
|
{ key: 'rowNumber', label: '번호', className: 'text-center' },
|
||||||
|
{ key: 'serialNumber', label: '일련번호' },
|
||||||
|
{ key: 'name', label: '상품권명' },
|
||||||
|
{ key: 'faceValue', label: '액면가', className: 'text-right' },
|
||||||
|
{ key: 'purchaseDate', label: '구입일', className: 'text-center' },
|
||||||
|
{ key: 'usedDate', label: '사용일', className: 'text-center' },
|
||||||
|
{ key: 'entertainmentExpense', label: '접대비', className: 'text-center' },
|
||||||
|
{ key: 'status', label: '상태', className: 'text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 기본 Summary =====
|
||||||
|
const DEFAULT_SUMMARY = {
|
||||||
|
totalCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
holdingCount: 0,
|
||||||
|
holdingAmount: 0,
|
||||||
|
usedCount: 0,
|
||||||
|
usedAmount: 0,
|
||||||
|
entertainmentCount: 0,
|
||||||
|
entertainmentAmount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GiftCertificateManagement() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ===== 데이터 상태 =====
|
||||||
|
const [data, setData] = useState<GiftCertificateRecord[]>([]);
|
||||||
|
const [summary, setSummary] = useState(DEFAULT_SUMMARY);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [entertainmentFilter, setEntertainmentFilter] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// 날짜 범위
|
||||||
|
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||||
|
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||||
|
|
||||||
|
const formatAmount = (amount: number) => amount.toLocaleString('ko-KR');
|
||||||
|
|
||||||
|
// ===== 데이터 로드 =====
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, summaryResult] = await Promise.all([
|
||||||
|
getGiftCertificates({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
}),
|
||||||
|
getGiftCertificateSummary({ startDate, endDate }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success) {
|
||||||
|
setData(listResult.data ?? []);
|
||||||
|
}
|
||||||
|
if (summaryResult.success && summaryResult.data) {
|
||||||
|
setSummary(summaryResult.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
console.error('[GiftCertificateManagement] loadData error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 핸들러 =====
|
||||||
|
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
|
||||||
|
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
router.push('/accounting/gift-certificates?mode=new');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// ===== UniversalListPage Config =====
|
||||||
|
const config: UniversalListConfig<GiftCertificateRecord> = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: '상품권 관리',
|
||||||
|
description: '상품권을 등록하고 관리합니다.',
|
||||||
|
icon: Gift,
|
||||||
|
basePath: '/accounting/gift-certificates',
|
||||||
|
idField: 'id',
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
getList: async () => ({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
totalCount: data.length,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: tableColumns,
|
||||||
|
clientSideFiltering: true,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
showCheckbox: true,
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
searchPlaceholder: '상품권명, 일련번호 검색...',
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
searchFilter: (item: GiftCertificateRecord, search: string) => {
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(s) ||
|
||||||
|
item.serialNumber.toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 날짜 범위 (이번달~D-5달 프리셋)
|
||||||
|
dateRangeSelector: {
|
||||||
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
|
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||||
|
presetLabels: {
|
||||||
|
thisMonth: '이번달',
|
||||||
|
lastMonth: '지난달',
|
||||||
|
twoMonthsAgo: 'D-2달',
|
||||||
|
threeMonthsAgo: 'D-3달',
|
||||||
|
fourMonthsAgo: 'D-4달',
|
||||||
|
fiveMonthsAgo: 'D-5달',
|
||||||
|
},
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange: setStartDate,
|
||||||
|
onEndDateChange: setEndDate,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 헤더 액션: 상품권 등록
|
||||||
|
headerActions: () => (
|
||||||
|
<Button size="sm" onClick={handleCreate}>
|
||||||
|
<PlusCircle className="h-4 w-4 mr-1" />
|
||||||
|
상품권 등록
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
|
||||||
|
// 테이블 헤더 액션: 총 N건 + 상태 필터 + 접대비 필터
|
||||||
|
tableHeaderActions: () => (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
총 {data.length}건
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={setStatusFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 접대비 필터 */}
|
||||||
|
<Select
|
||||||
|
value={entertainmentFilter}
|
||||||
|
onValueChange={setEntertainmentFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[130px]">
|
||||||
|
<SelectValue placeholder="접대비" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTERTAINMENT_FILTER_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
// 클라이언트 사이드 커스텀 필터 (상태 + 접대비)
|
||||||
|
customFilterFn: (items) => {
|
||||||
|
let filtered = items;
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter((item) => item.status === statusFilter);
|
||||||
|
}
|
||||||
|
if (entertainmentFilter !== 'all') {
|
||||||
|
filtered = filtered.filter((item) => item.entertainmentExpense === entertainmentFilter);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 통계 카드 4개 (기획서: 전체 상품권, 보유 상품권, 사용 상품권, 접대비 해당)
|
||||||
|
computeStats: (): StatCard[] => [
|
||||||
|
{
|
||||||
|
label: '전체 상품권',
|
||||||
|
value: `${summary.totalCount}건 / ${formatAmount(summary.totalAmount)}원`,
|
||||||
|
icon: Gift,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '보유 상품권',
|
||||||
|
value: `${summary.holdingCount}건 / ${formatAmount(summary.holdingAmount)}원`,
|
||||||
|
icon: Gift,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '사용 상품권',
|
||||||
|
value: `${summary.usedCount}건 / ${formatAmount(summary.usedAmount)}원`,
|
||||||
|
icon: Gift,
|
||||||
|
iconColor: 'text-orange-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '접대비 해당',
|
||||||
|
value: `${summary.entertainmentCount}건 / ${formatAmount(summary.entertainmentAmount)}원`,
|
||||||
|
icon: Gift,
|
||||||
|
iconColor: 'text-purple-600',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
renderTableRow: (
|
||||||
|
item: GiftCertificateRecord,
|
||||||
|
_index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<GiftCertificateRecord>
|
||||||
|
) => {
|
||||||
|
const statusInfo = STATUS_MAP[item.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
|
{/* 체크박스 */}
|
||||||
|
<TableCell className="text-center w-[40px]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={handlers.isSelected}
|
||||||
|
onCheckedChange={() => handlers.onToggle()}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
{/* 번호 */}
|
||||||
|
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||||
|
{/* 일련번호 */}
|
||||||
|
<TableCell>{item.serialNumber}</TableCell>
|
||||||
|
{/* 상품권명 */}
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
{/* 액면가 */}
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(item.faceValue)}
|
||||||
|
</TableCell>
|
||||||
|
{/* 구입일 */}
|
||||||
|
<TableCell className="text-center">{item.purchaseDate}</TableCell>
|
||||||
|
{/* 사용일 */}
|
||||||
|
<TableCell className="text-center">{item.usedDate || '-'}</TableCell>
|
||||||
|
{/* 접대비 */}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{item.entertainmentExpense === 'applicable' ? '해당' : '-'}
|
||||||
|
</TableCell>
|
||||||
|
{/* 상태 */}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
renderMobileCard: (
|
||||||
|
item: GiftCertificateRecord,
|
||||||
|
_index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<GiftCertificateRecord>
|
||||||
|
) => {
|
||||||
|
const statusInfo = STATUS_MAP[item.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
isSelected={handlers.isSelected}
|
||||||
|
onToggle={handlers.onToggle}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
title={`${globalIndex}. ${item.name}`}
|
||||||
|
badge={{
|
||||||
|
label: statusInfo.label,
|
||||||
|
variant: item.status === 'holding'
|
||||||
|
? 'default'
|
||||||
|
: item.status === 'used'
|
||||||
|
? 'secondary'
|
||||||
|
: 'destructive',
|
||||||
|
}}
|
||||||
|
infoGrid={
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">상품권번호</span> <span>{item.serialNumber}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">액면가</span> <span>{formatAmount(item.faceValue)}원</span></div>
|
||||||
|
<div><span className="text-muted-foreground">구입일</span> <span>{item.purchaseDate}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">사용일</span> <span>{item.usedDate || '-'}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">접대비</span> <span>{item.entertainmentExpense === 'applicable' ? '해당' : '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UniversalListPage
|
||||||
|
config={config}
|
||||||
|
initialData={data}
|
||||||
|
initialTotalCount={data.length}
|
||||||
|
externalIsLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/accounting/GiftCertificateManagement/types.ts
Normal file
106
src/components/accounting/GiftCertificateManagement/types.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 상품권 관리 - 타입 및 상수 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 상품권 상태 =====
|
||||||
|
export type GiftCertificateStatus = 'holding' | 'used' | 'disposed';
|
||||||
|
|
||||||
|
export const STATUS_MAP: Record<GiftCertificateStatus, { label: string; color: string }> = {
|
||||||
|
holding: { label: '보유', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
used: { label: '사용', color: 'bg-green-100 text-green-700' },
|
||||||
|
disposed: { label: '폐기', color: 'bg-red-100 text-red-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 구입목적 =====
|
||||||
|
export type PurchasePurpose = 'promotion' | 'gift' | 'entertainment' | 'other';
|
||||||
|
|
||||||
|
// ===== 접대비 해당 여부 =====
|
||||||
|
export type EntertainmentExpense = 'applicable' | 'not_applicable';
|
||||||
|
|
||||||
|
// ===== 목록용 레코드 =====
|
||||||
|
export interface GiftCertificateRecord {
|
||||||
|
id: string;
|
||||||
|
serialNumber: string;
|
||||||
|
name: string;
|
||||||
|
faceValue: number;
|
||||||
|
purchaseDate: string;
|
||||||
|
usedDate: string | null;
|
||||||
|
status: GiftCertificateStatus;
|
||||||
|
entertainmentExpense: EntertainmentExpense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상세/폼 데이터 =====
|
||||||
|
export interface GiftCertificateFormData {
|
||||||
|
serialNumber: string;
|
||||||
|
name: string;
|
||||||
|
faceValue: number;
|
||||||
|
vendorId: string;
|
||||||
|
vendorName: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
purchasePurpose: PurchasePurpose;
|
||||||
|
entertainmentExpense: EntertainmentExpense;
|
||||||
|
status: GiftCertificateStatus;
|
||||||
|
// 상품권 정보 (액면가 50만원 이상 시 필수)
|
||||||
|
usedDate: string;
|
||||||
|
recipientName: string;
|
||||||
|
recipientOrganization: string;
|
||||||
|
usageDescription: string;
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 상태 =====
|
||||||
|
export interface FilterState {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: string;
|
||||||
|
entertainmentExpense: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Select 옵션 상수 =====
|
||||||
|
export const STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'holding', label: '보유' },
|
||||||
|
{ value: 'used', label: '사용' },
|
||||||
|
{ value: 'disposed', label: '폐기' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PURCHASE_PURPOSE_OPTIONS = [
|
||||||
|
{ value: 'promotion', label: '판촉' },
|
||||||
|
{ value: 'gift', label: '선물' },
|
||||||
|
{ value: 'entertainment', label: '접대' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTERTAINMENT_EXPENSE_OPTIONS = [
|
||||||
|
{ value: 'applicable', label: '해당' },
|
||||||
|
{ value: 'not_applicable', label: '비해당' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTERTAINMENT_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: '접대비 전체' },
|
||||||
|
{ value: 'applicable', label: '해당' },
|
||||||
|
{ value: 'not_applicable', label: '비해당' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 초기 폼 데이터 =====
|
||||||
|
export function createEmptyFormData(): GiftCertificateFormData {
|
||||||
|
return {
|
||||||
|
serialNumber: '',
|
||||||
|
name: '',
|
||||||
|
faceValue: 0,
|
||||||
|
vendorId: '',
|
||||||
|
vendorName: '',
|
||||||
|
purchaseDate: '',
|
||||||
|
purchasePurpose: 'promotion',
|
||||||
|
entertainmentExpense: 'not_applicable',
|
||||||
|
status: 'holding',
|
||||||
|
usedDate: '',
|
||||||
|
recipientName: '',
|
||||||
|
recipientOrganization: '',
|
||||||
|
usageDescription: '',
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 액면가 50만원 기준 =====
|
||||||
|
export const FACE_VALUE_THRESHOLD = 500000;
|
||||||
@@ -534,7 +534,7 @@ export function PurchaseManagement() {
|
|||||||
(으)로 모두 변경하시겠습니까?
|
(으)로 모두 변경하시겠습니까?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
(으)로 모두 변경하시겠습니까?
|
(으)로 모두 변경하시겠습니까?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { StandardDialog } from '@/components/molecules/StandardDialog';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { saveSupplierSettings } from './actions';
|
||||||
|
import type { SupplierSettings } from './types';
|
||||||
|
|
||||||
|
interface SupplierSettingModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
settings: SupplierSettings;
|
||||||
|
onSaved: (settings: SupplierSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierSettingModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
settings,
|
||||||
|
onSaved,
|
||||||
|
}: SupplierSettingModalProps) {
|
||||||
|
const [formData, setFormData] = useState<SupplierSettings>(settings);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormData(settings);
|
||||||
|
}
|
||||||
|
}, [open, settings]);
|
||||||
|
|
||||||
|
const handleChange = (field: keyof SupplierSettings, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.companyName.trim()) {
|
||||||
|
toast.error('상호명을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.representativeName.trim()) {
|
||||||
|
toast.error('대표자명을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await saveSupplierSettings(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('공급자 정보가 저장되었습니다.');
|
||||||
|
onSaved(result.data ?? formData);
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StandardDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="공급자 기초정보 설정"
|
||||||
|
description="세금계산서에 표시될 공급자 정보를 설정합니다."
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2 px-6 pb-4">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="사업자번호"
|
||||||
|
type="businessNumber"
|
||||||
|
value={formData.businessNumber}
|
||||||
|
onChange={(v) => handleChange('businessNumber', v)}
|
||||||
|
placeholder="000-00-00000"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="상호명"
|
||||||
|
required
|
||||||
|
value={formData.companyName}
|
||||||
|
onChange={(v) => handleChange('companyName', v)}
|
||||||
|
placeholder="상호명을 입력하세요"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="대표자명"
|
||||||
|
required
|
||||||
|
value={formData.representativeName}
|
||||||
|
onChange={(v) => handleChange('representativeName', v)}
|
||||||
|
placeholder="대표자명을 입력하세요"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="주소"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(v) => handleChange('address', v)}
|
||||||
|
placeholder="사업장 주소를 입력하세요"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="업태"
|
||||||
|
value={formData.businessType}
|
||||||
|
onChange={(v) => handleChange('businessType', v)}
|
||||||
|
placeholder="업태"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="종목"
|
||||||
|
value={formData.businessItem}
|
||||||
|
onChange={(v) => handleChange('businessItem', v)}
|
||||||
|
placeholder="종목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="담당자명"
|
||||||
|
value={formData.contactName}
|
||||||
|
onChange={(v) => handleChange('contactName', v)}
|
||||||
|
placeholder="담당자명"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="연락처"
|
||||||
|
type="phone"
|
||||||
|
value={formData.contactPhone}
|
||||||
|
onChange={(v) => handleChange('contactPhone', v)}
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="이메일"
|
||||||
|
value={formData.contactEmail}
|
||||||
|
onChange={(v) => handleChange('contactEmail', v)}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StandardDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { PageLayout, PageHeader } from '@/components/organisms';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { TAX_INVOICE_STATUS_MAP } from './types';
|
||||||
|
import type {
|
||||||
|
TaxInvoiceFormData,
|
||||||
|
TaxInvoiceRecord,
|
||||||
|
BusinessEntity,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface TaxInvoiceDetailProps {
|
||||||
|
id: string;
|
||||||
|
initialData?: TaxInvoiceFormData & {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
status: TaxInvoiceRecord['status'];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceDetail({ id, initialData }: TaxInvoiceDetailProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const renderEntityInfo = (entity: BusinessEntity, label: string) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{label}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-y-1.5 gap-x-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">사업자번호</span>
|
||||||
|
<span>{entity.businessNumber || '-'}</span>
|
||||||
|
<span className="text-muted-foreground">상호</span>
|
||||||
|
<span>{entity.companyName || '-'}</span>
|
||||||
|
<span className="text-muted-foreground">대표자</span>
|
||||||
|
<span>{entity.representativeName || '-'}</span>
|
||||||
|
<span className="text-muted-foreground">주소</span>
|
||||||
|
<span>{entity.address || '-'}</span>
|
||||||
|
<span className="text-muted-foreground">업태</span>
|
||||||
|
<span>{entity.businessType || '-'}</span>
|
||||||
|
<span className="text-muted-foreground">종목</span>
|
||||||
|
<span>{entity.businessItem || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!initialData) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="세금계산서 상세"
|
||||||
|
description="세금계산서 정보를 확인합니다."
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
세금계산서를 찾을 수 없습니다. (ID: {id})
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/ko/accounting/tax-invoice-issuance')}
|
||||||
|
>
|
||||||
|
목록으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = TAX_INVOICE_STATUS_MAP[initialData.status];
|
||||||
|
const totalSupply = initialData.items.reduce((sum, item) => sum + item.supplyAmount, 0);
|
||||||
|
const totalTax = initialData.items.reduce((sum, item) => sum + item.taxAmount, 0);
|
||||||
|
const totalAmount = initialData.items.reduce((sum, item) => sum + item.totalAmount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="세금계산서 상세"
|
||||||
|
description="세금계산서 정보를 확인합니다."
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block">발행번호</span>
|
||||||
|
<span className="font-medium">{initialData.invoiceNumber}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block">작성일자</span>
|
||||||
|
<span>{initialData.writeDate}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block">상태</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground block">합계금액</span>
|
||||||
|
<span className="font-medium">{totalAmount.toLocaleString('ko-KR')}원</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 공급자 / 공급받는자 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{renderEntityInfo(initialData.supplier, '공급자')}
|
||||||
|
{renderEntityInfo(initialData.receiver, '공급받는자')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 테이블 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">품목 내역</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">No.</TableHead>
|
||||||
|
<TableHead className="w-[40px] text-center">월</TableHead>
|
||||||
|
<TableHead className="w-[40px] text-center">일</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-right">단가</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-right">세액</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">합계</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{initialData.items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.month || '-'}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.day || '-'}</TableCell>
|
||||||
|
<TableCell>{item.itemName || '-'}</TableCell>
|
||||||
|
<TableCell>{item.specification || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.quantity.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.unitPrice.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.supplyAmount.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.taxAmount.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{item.totalAmount.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow className="bg-muted/30 font-medium">
|
||||||
|
<TableCell colSpan={7} className="text-right">합계</TableCell>
|
||||||
|
<TableCell className="text-right">{totalSupply.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right">{totalTax.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
<TableCell className="text-right">{totalAmount.toLocaleString('ko-KR')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
{initialData.memo && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">비고</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{initialData.memo}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/ko/accounting/tax-invoice-issuance')}
|
||||||
|
>
|
||||||
|
목록으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx
Normal file
215
src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { TaxInvoiceItemTable } from './TaxInvoiceItemTable';
|
||||||
|
import { createEmptyItem, createEmptyBusinessEntity } from './types';
|
||||||
|
import type { BusinessEntity, TaxInvoiceItem, TaxInvoiceFormData } from './types';
|
||||||
|
|
||||||
|
interface TaxInvoiceFormProps {
|
||||||
|
supplier: BusinessEntity;
|
||||||
|
onSubmit: (data: TaxInvoiceFormData) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onVendorSearch: () => void;
|
||||||
|
selectedVendor: BusinessEntity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceForm({
|
||||||
|
supplier,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
onVendorSearch,
|
||||||
|
selectedVendor,
|
||||||
|
}: TaxInvoiceFormProps) {
|
||||||
|
const [writeDate, setWriteDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||||
|
const [items, setItems] = useState<TaxInvoiceItem[]>([createEmptyItem()]);
|
||||||
|
const [memo, setMemo] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const receiver = selectedVendor ?? createEmptyBusinessEntity();
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!selectedVendor) {
|
||||||
|
toast.error('공급받는자를 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
toast.error('품목을 하나 이상 추가하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasEmptyItem = items.some((item) => !item.itemName.trim());
|
||||||
|
if (hasEmptyItem) {
|
||||||
|
toast.error('품목명을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
supplier,
|
||||||
|
receiver,
|
||||||
|
writeDate,
|
||||||
|
items,
|
||||||
|
memo,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [supplier, receiver, writeDate, items, memo, selectedVendor, onSubmit]);
|
||||||
|
|
||||||
|
const thClass = 'border border-gray-300 bg-gray-50 px-2 py-1.5 text-left font-medium whitespace-nowrap w-[70px] text-xs';
|
||||||
|
const tdClass = 'border border-gray-300 px-2 py-1.5 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
{/* 공급자 / 공급받는자 테이블 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse table-fixed text-sm min-w-[800px]">
|
||||||
|
<colgroup>
|
||||||
|
{/* 공급자 */}
|
||||||
|
<col style={{ width: '30px' }} />
|
||||||
|
<col style={{ width: '70px' }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: '70px' }} />
|
||||||
|
<col />
|
||||||
|
{/* 공급받는자 */}
|
||||||
|
<col style={{ width: '30px' }} />
|
||||||
|
<col style={{ width: '70px' }} />
|
||||||
|
<col />
|
||||||
|
<col style={{ width: '70px' }} />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{/* Row 1: 등록번호 / 종사업장 */}
|
||||||
|
<tr>
|
||||||
|
<td rowSpan={6} className="border border-gray-300 bg-gray-100 text-center font-semibold w-8 align-middle">
|
||||||
|
<div className="[writing-mode:vertical-rl] mx-auto tracking-widest">공급자</div>
|
||||||
|
</td>
|
||||||
|
<th className={thClass}>등록번호</th>
|
||||||
|
<td className={tdClass}>{supplier.businessNumber || '-'}</td>
|
||||||
|
<th className={thClass}>종사업장</th>
|
||||||
|
<td className={tdClass}></td>
|
||||||
|
|
||||||
|
<td rowSpan={6} className="border border-gray-300 bg-gray-100 text-center font-semibold w-8 align-middle">
|
||||||
|
<div className="[writing-mode:vertical-rl] mx-auto tracking-widest">공급받는자</div>
|
||||||
|
</td>
|
||||||
|
<th className={thClass}>등록번호</th>
|
||||||
|
<td className={tdClass}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex-1">{receiver.businessNumber || ''}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onVendorSearch} className="h-6 text-xs px-2 shrink-0">
|
||||||
|
<Search className="h-3 w-3 mr-1" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<th className={thClass}>종사업장</th>
|
||||||
|
<td className={tdClass}></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Row 2: 상호 / 대표자 */}
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>상호</th>
|
||||||
|
<td className={tdClass}>{supplier.companyName || ''}</td>
|
||||||
|
<th className={thClass}>대표자</th>
|
||||||
|
<td className={tdClass}>{supplier.representativeName || ''}</td>
|
||||||
|
|
||||||
|
<th className={thClass}>상호</th>
|
||||||
|
<td className={tdClass}>{receiver.companyName || ''}</td>
|
||||||
|
<th className={thClass}>대표자</th>
|
||||||
|
<td className={tdClass}>{receiver.representativeName || ''}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Row 3: 사업장주소 */}
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>사업장주소</th>
|
||||||
|
<td className={tdClass} colSpan={3}>{supplier.address || ''}</td>
|
||||||
|
|
||||||
|
<th className={thClass}>사업장주소</th>
|
||||||
|
<td className={tdClass} colSpan={3}>{receiver.address || ''}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Row 4: 업태 / 종목 */}
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>업태</th>
|
||||||
|
<td className={tdClass}>{supplier.businessType || ''}</td>
|
||||||
|
<th className={thClass}>종목</th>
|
||||||
|
<td className={tdClass}>{supplier.businessItem || ''}</td>
|
||||||
|
|
||||||
|
<th className={thClass}>업태</th>
|
||||||
|
<td className={tdClass}>{receiver.businessType || ''}</td>
|
||||||
|
<th className={thClass}>종목</th>
|
||||||
|
<td className={tdClass}>{receiver.businessItem || ''}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Row 5: 담당자 / 연락처 */}
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>담당자</th>
|
||||||
|
<td className={tdClass}>{supplier.contactName || ''}</td>
|
||||||
|
<th className={thClass}>연락처</th>
|
||||||
|
<td className={tdClass}>{supplier.contactPhone || ''}</td>
|
||||||
|
|
||||||
|
<th className={thClass}>담당자</th>
|
||||||
|
<td className={tdClass}>{receiver.contactName || ''}</td>
|
||||||
|
<th className={thClass}>연락처</th>
|
||||||
|
<td className={tdClass}>{receiver.contactPhone || ''}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Row 6: 이메일 */}
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>이메일</th>
|
||||||
|
<td className={tdClass} colSpan={3}>{supplier.contactEmail || ''}</td>
|
||||||
|
|
||||||
|
<th className={thClass}>이메일</th>
|
||||||
|
<td className={tdClass} colSpan={3}>
|
||||||
|
{receiver.contactEmail || <span className="text-muted-foreground text-xs">세금계산서 수신 이메일</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성일자 */}
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<Label className="mb-1 block">작성일자</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={writeDate}
|
||||||
|
onChange={setWriteDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 테이블 */}
|
||||||
|
<TaxInvoiceItemTable items={items} onItemsChange={setItems} />
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block">비고</Label>
|
||||||
|
<Textarea
|
||||||
|
value={memo}
|
||||||
|
onChange={(e) => setMemo(e.target.value)}
|
||||||
|
placeholder="비고 사항을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? '발행 중...' : '세금계산서 발행'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { createEmptyItem, TAX_TYPE_OPTIONS } from './types';
|
||||||
|
import type { TaxInvoiceItem } from './types';
|
||||||
|
|
||||||
|
interface TaxInvoiceItemTableProps {
|
||||||
|
items: TaxInvoiceItem[];
|
||||||
|
onItemsChange: (items: TaxInvoiceItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceItemTable({ items, onItemsChange }: TaxInvoiceItemTableProps) {
|
||||||
|
const updateItem = useCallback(
|
||||||
|
(id: string, field: keyof TaxInvoiceItem, value: string | number) => {
|
||||||
|
onItemsChange(
|
||||||
|
items.map((item) => {
|
||||||
|
if (item.id !== id) return item;
|
||||||
|
|
||||||
|
const updated = { ...item, [field]: value };
|
||||||
|
|
||||||
|
// 자동 계산: 수량, 단가, 과세유형 변경 시
|
||||||
|
if (field === 'quantity' || field === 'unitPrice' || field === 'taxType') {
|
||||||
|
const qty = field === 'quantity' ? Number(value) : updated.quantity;
|
||||||
|
const price = field === 'unitPrice' ? Number(value) : updated.unitPrice;
|
||||||
|
const taxType = field === 'taxType' ? (value as 'taxable' | 'taxFree') : updated.taxType;
|
||||||
|
|
||||||
|
updated.supplyAmount = qty * price;
|
||||||
|
updated.taxAmount = taxType === 'taxable' ? Math.floor(updated.supplyAmount * 0.1) : 0;
|
||||||
|
updated.totalAmount = updated.supplyAmount + updated.taxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[items, onItemsChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = useCallback(() => {
|
||||||
|
onItemsChange([...items, createEmptyItem()]);
|
||||||
|
}, [items, onItemsChange]);
|
||||||
|
|
||||||
|
const removeItem = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onItemsChange(items.filter((item) => item.id !== id));
|
||||||
|
},
|
||||||
|
[items, onItemsChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return num.toLocaleString('ko-KR');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
const totals = items.reduce(
|
||||||
|
(acc, item) => ({
|
||||||
|
supplyAmount: acc.supplyAmount + item.supplyAmount,
|
||||||
|
taxAmount: acc.taxAmount + item.taxAmount,
|
||||||
|
totalAmount: acc.totalAmount + item.totalAmount,
|
||||||
|
}),
|
||||||
|
{ supplyAmount: 0, taxAmount: 0, totalAmount: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium">품목 내역</h4>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addItem}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
품목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] text-center">월</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center">일</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품목</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">단가</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">세액</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">합계</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center">과세</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.month}
|
||||||
|
onChange={(e) => updateItem(item.id, 'month', e.target.value)}
|
||||||
|
placeholder="MM"
|
||||||
|
className="h-8 text-center text-sm"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.day}
|
||||||
|
onChange={(e) => updateItem(item.id, 'day', e.target.value)}
|
||||||
|
placeholder="DD"
|
||||||
|
className="h-8 text-center text-sm"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.itemName}
|
||||||
|
onChange={(e) => updateItem(item.id, 'itemName', e.target.value)}
|
||||||
|
placeholder="품목명"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
value={item.specification}
|
||||||
|
onChange={(e) => updateItem(item.id, 'specification', e.target.value)}
|
||||||
|
placeholder="규격"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity || ''}
|
||||||
|
onChange={(e) => updateItem(item.id, 'quantity', Number(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice || ''}
|
||||||
|
onChange={(e) => updateItem(item.id, 'unitPrice', Number(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-right text-sm font-medium">
|
||||||
|
{formatNumber(item.supplyAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-right text-sm">
|
||||||
|
{formatNumber(item.taxAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1 text-right text-sm font-medium">
|
||||||
|
{formatNumber(item.totalAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={item.taxType}
|
||||||
|
onValueChange={(v) => updateItem(item.id, 'taxType', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TAX_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="text-center text-muted-foreground py-8">
|
||||||
|
품목을 추가하세요.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 합계 행 */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={6} className="text-right text-sm pr-4">
|
||||||
|
합계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm p-1">
|
||||||
|
{formatNumber(totals.supplyAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm p-1">
|
||||||
|
{formatNumber(totals.taxAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm p-1">
|
||||||
|
{formatNumber(totals.totalAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/components/accounting/TaxInvoiceIssuance/actions.ts
Normal file
175
src/components/accounting/TaxInvoiceIssuance/actions.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 세금계산서 발행 서버 액션 (Mock)
|
||||||
|
*
|
||||||
|
* API Endpoints (예정):
|
||||||
|
* - GET /api/v1/tax-invoices - 목록 조회
|
||||||
|
* - POST /api/v1/tax-invoices - 발행
|
||||||
|
* - GET /api/v1/tax-invoices/supplier-settings - 공급자 설정 조회
|
||||||
|
* - PUT /api/v1/tax-invoices/supplier-settings - 공급자 설정 저장
|
||||||
|
* - GET /api/v1/tax-invoices/vendors - 거래처 검색
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import type { ActionResult } from '@/lib/api/execute-server-action';
|
||||||
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
import type {
|
||||||
|
TaxInvoiceRecord,
|
||||||
|
TaxInvoiceFormData,
|
||||||
|
SupplierSettings,
|
||||||
|
VendorSearchItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 세금계산서 목록 조회 (Mock) =====
|
||||||
|
export async function getTaxInvoices(_params?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
dateType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
vendorSearch?: string;
|
||||||
|
status?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
}): Promise<ActionResult<TaxInvoiceRecord[]>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executePaginatedAction<TaxInvoiceApiData, TaxInvoiceRecord>({
|
||||||
|
// url: buildApiUrl('/api/v1/tax-invoices', {
|
||||||
|
// page: params?.page,
|
||||||
|
// per_page: params?.perPage,
|
||||||
|
// date_type: params?.dateType,
|
||||||
|
// start_date: params?.startDate,
|
||||||
|
// end_date: params?.endDate,
|
||||||
|
// vendor_search: params?.vendorSearch,
|
||||||
|
// status: params?.status !== 'all' ? params?.status : undefined,
|
||||||
|
// sort_by: params?.sortBy,
|
||||||
|
// sort_order: params?.sortOrder,
|
||||||
|
// }),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 세금계산서 발행 (Mock) =====
|
||||||
|
export async function createTaxInvoice(
|
||||||
|
_data: TaxInvoiceFormData
|
||||||
|
): Promise<ActionResult<TaxInvoiceRecord>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl('/api/v1/tax-invoices'),
|
||||||
|
// method: 'POST',
|
||||||
|
// body: transformFrontendToApi(data),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '세금계산서 발행에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
invoiceNumber: `TI-${Date.now()}`,
|
||||||
|
vendorName: _data.receiver.companyName,
|
||||||
|
vendorBusinessNumber: _data.receiver.businessNumber,
|
||||||
|
writeDate: _data.writeDate,
|
||||||
|
sendDate: null,
|
||||||
|
supplyAmount: _data.items.reduce((sum, item) => sum + item.supplyAmount, 0),
|
||||||
|
taxAmount: _data.items.reduce((sum, item) => sum + item.taxAmount, 0),
|
||||||
|
totalAmount: _data.items.reduce((sum, item) => sum + item.totalAmount, 0),
|
||||||
|
status: 'draft',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 공급자 설정 조회 (Mock) =====
|
||||||
|
export async function getSupplierSettings(): Promise<ActionResult<SupplierSettings>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl('/api/v1/tax-invoices/supplier-settings'),
|
||||||
|
// transform: transformSupplierSettings,
|
||||||
|
// errorMessage: '공급자 설정 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
businessNumber: '1231212345',
|
||||||
|
companyName: '상호명',
|
||||||
|
representativeName: '홍길동',
|
||||||
|
address: '주소명',
|
||||||
|
businessType: '업태명',
|
||||||
|
businessItem: '종목명',
|
||||||
|
contactName: '홍길동',
|
||||||
|
contactPhone: '02-123-1234',
|
||||||
|
contactEmail: 'abc@email.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 공급자 설정 저장 (Mock) =====
|
||||||
|
export async function saveSupplierSettings(
|
||||||
|
_data: SupplierSettings
|
||||||
|
): Promise<ActionResult<SupplierSettings>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl('/api/v1/tax-invoices/supplier-settings'),
|
||||||
|
// method: 'PUT',
|
||||||
|
// body: transformSupplierSettingsToApi(data),
|
||||||
|
// transform: transformSupplierSettings,
|
||||||
|
// errorMessage: '공급자 설정 저장에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: true, data: _data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 세금계산서 상세 조회 (Mock) =====
|
||||||
|
export async function getTaxInvoiceById(
|
||||||
|
_id: string
|
||||||
|
): Promise<ActionResult<TaxInvoiceFormData & { id: string; invoiceNumber: string; status: TaxInvoiceRecord['status'] }>> {
|
||||||
|
// TODO: 실제 API 연동 시 교체
|
||||||
|
// return executeServerAction({
|
||||||
|
// url: buildApiUrl(`/api/v1/tax-invoices/${id}`),
|
||||||
|
// transform: transformDetailApiToFrontend,
|
||||||
|
// errorMessage: '세금계산서 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: false, error: '세금계산서를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 거래처 검색 (/api/v1/clients 연동) =====
|
||||||
|
interface ClientApiData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
business_no: string;
|
||||||
|
contact_person: string;
|
||||||
|
address: string;
|
||||||
|
business_type: string;
|
||||||
|
business_item: string;
|
||||||
|
manager_name: string;
|
||||||
|
manager_tel: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVendorsForTaxInvoice(
|
||||||
|
query: string
|
||||||
|
): Promise<VendorSearchItem[]> {
|
||||||
|
const result = await executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/clients', {
|
||||||
|
q: query || undefined,
|
||||||
|
only_active: true,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
transform: (data: { data: ClientApiData[] }) =>
|
||||||
|
data.data.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
companyName: item.name || '',
|
||||||
|
businessNumber: item.business_no || '',
|
||||||
|
representativeName: item.contact_person || '',
|
||||||
|
address: item.address || '',
|
||||||
|
businessType: item.business_type || '',
|
||||||
|
businessItem: item.business_item || '',
|
||||||
|
contactName: item.manager_name || '',
|
||||||
|
contactPhone: item.manager_tel || '',
|
||||||
|
contactEmail: item.email || '',
|
||||||
|
})),
|
||||||
|
errorMessage: '거래처 검색에 실패했습니다.',
|
||||||
|
});
|
||||||
|
return result.success ? (result.data ?? []) : [];
|
||||||
|
}
|
||||||
482
src/components/accounting/TaxInvoiceIssuance/index.tsx
Normal file
482
src/components/accounting/TaxInvoiceIssuance/index.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { format, subDays, subMonths } from 'date-fns';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import {
|
||||||
|
PageLayout,
|
||||||
|
PageHeader,
|
||||||
|
StatCards,
|
||||||
|
EmptyState,
|
||||||
|
SearchableSelectionModal,
|
||||||
|
} from '@/components/organisms';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { FileText, DollarSign, Calculator, Hash, Settings, PlusCircle, Search } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { SupplierSettingModal } from './SupplierSettingModal';
|
||||||
|
import { TaxInvoiceForm } from './TaxInvoiceForm';
|
||||||
|
import { createTaxInvoice, searchVendorsForTaxInvoice } from './actions';
|
||||||
|
import {
|
||||||
|
DATE_TYPE_OPTIONS,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
SORT_BY_OPTIONS,
|
||||||
|
SORT_ORDER_OPTIONS,
|
||||||
|
TAX_INVOICE_STATUS_MAP,
|
||||||
|
createEmptyBusinessEntity,
|
||||||
|
} from './types';
|
||||||
|
import type {
|
||||||
|
TaxInvoiceRecord,
|
||||||
|
SupplierSettings,
|
||||||
|
VendorSearchItem,
|
||||||
|
FilterState,
|
||||||
|
TaxInvoiceFormData,
|
||||||
|
BusinessEntity,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface TaxInvoiceIssuancePageProps {
|
||||||
|
initialData: TaxInvoiceRecord[];
|
||||||
|
initialSupplierSettings: SupplierSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceIssuancePage({
|
||||||
|
initialData,
|
||||||
|
initialSupplierSettings,
|
||||||
|
}: TaxInvoiceIssuancePageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [records, setRecords] = useState<TaxInvoiceRecord[]>(initialData);
|
||||||
|
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings>(initialSupplierSettings);
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
const [showSupplierModal, setShowSupplierModal] = useState(false);
|
||||||
|
const [showVendorSearch, setShowVendorSearch] = useState(false);
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<BusinessEntity | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
dateType: 'write',
|
||||||
|
startDate: format(subMonths(new Date(), 1), 'yyyy-MM-dd'),
|
||||||
|
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
vendorSearch: '',
|
||||||
|
status: 'all',
|
||||||
|
sortBy: 'writeDate',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFilter = useCallback((field: keyof FilterState, value: string) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
const totalSupply = records.reduce((sum, r) => sum + r.supplyAmount, 0);
|
||||||
|
const totalTax = records.reduce((sum, r) => sum + r.taxAmount, 0);
|
||||||
|
const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
|
||||||
|
const issuedCount = records.filter((r) => r.status === 'issued' || r.status === 'nts_sent').length;
|
||||||
|
const sentCount = records.filter((r) => r.status === 'nts_sent').length;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: '발행건수',
|
||||||
|
sublabel: `발행 ${issuedCount} / 전송 ${sentCount}`,
|
||||||
|
value: `${totalAmount.toLocaleString('ko-KR')}원`,
|
||||||
|
icon: Hash,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '총 합계금액',
|
||||||
|
value: `${totalAmount.toLocaleString('ko-KR')}원`,
|
||||||
|
icon: Calculator,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '총 공급가액',
|
||||||
|
value: `${totalSupply.toLocaleString('ko-KR')}원`,
|
||||||
|
icon: DollarSign,
|
||||||
|
iconColor: 'text-purple-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '총 세액',
|
||||||
|
value: `${totalTax.toLocaleString('ko-KR')}원`,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-orange-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 세금계산서 발행 처리
|
||||||
|
const handleSubmitInvoice = useCallback(
|
||||||
|
async (data: TaxInvoiceFormData) => {
|
||||||
|
const result = await createTaxInvoice(data);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setRecords((prev) => [result.data!, ...prev]);
|
||||||
|
setShowNewForm(false);
|
||||||
|
setSelectedVendor(null);
|
||||||
|
toast.success('세금계산서가 발행되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '발행에 실패했습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 거래처 선택 처리
|
||||||
|
const handleVendorSelect = useCallback((vendor: VendorSearchItem) => {
|
||||||
|
setSelectedVendor({
|
||||||
|
businessNumber: vendor.businessNumber,
|
||||||
|
companyName: vendor.companyName,
|
||||||
|
representativeName: vendor.representativeName,
|
||||||
|
address: vendor.address,
|
||||||
|
businessType: vendor.businessType,
|
||||||
|
businessItem: vendor.businessItem,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
contactPhone: vendor.contactPhone,
|
||||||
|
contactEmail: vendor.contactEmail,
|
||||||
|
});
|
||||||
|
setShowVendorSearch(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 기간 단축 버튼
|
||||||
|
const handleQuickDate = useCallback(
|
||||||
|
(type: '1week' | '1month' | '3months') => {
|
||||||
|
const today = new Date();
|
||||||
|
const endDate = format(today, 'yyyy-MM-dd');
|
||||||
|
let startDate: string;
|
||||||
|
switch (type) {
|
||||||
|
case '1week':
|
||||||
|
startDate = format(subDays(today, 7), 'yyyy-MM-dd');
|
||||||
|
break;
|
||||||
|
case '1month':
|
||||||
|
startDate = format(subMonths(today, 1), 'yyyy-MM-dd');
|
||||||
|
break;
|
||||||
|
case '3months':
|
||||||
|
startDate = format(subMonths(today, 3), 'yyyy-MM-dd');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setFilters((prev) => ({ ...prev, startDate, endDate }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
// TODO: 실제 API 연동 시 필터 조건으로 getTaxInvoices 호출
|
||||||
|
toast.info('조회 기능은 API 연동 후 사용 가능합니다.');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="세금계산서 발행"
|
||||||
|
description="바로빌 API를 통하여 전자세금계산서를 발행하고 관리합니다"
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<StatCards stats={stats} />
|
||||||
|
|
||||||
|
{/* 전자세금계산서 발행 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||||
|
<CardTitle className="text-base">전자세금계산서 발행</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSupplierModal(true)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
|
공급자 설정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowNewForm(!showNewForm)}
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4 mr-1" />
|
||||||
|
{showNewForm ? '접기' : '새로 발행'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{showNewForm && (
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<TaxInvoiceForm
|
||||||
|
supplier={supplierSettings}
|
||||||
|
onSubmit={handleSubmitInvoice}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowNewForm(false);
|
||||||
|
setSelectedVendor(null);
|
||||||
|
}}
|
||||||
|
onVendorSearch={() => setShowVendorSearch(true)}
|
||||||
|
selectedVendor={selectedVendor}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
{/* Row1: 일자타입 + 날짜범위 + 기간 버튼 + 조회 */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={filters.dateType}
|
||||||
|
onValueChange={(v) => updateFilter('dateType', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full lg:w-[120px] h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={filters.startDate}
|
||||||
|
endDate={filters.endDate}
|
||||||
|
onStartDateChange={(v) => updateFilter('startDate', v)}
|
||||||
|
onEndDateChange={(v) => updateFilter('endDate', v)}
|
||||||
|
hidePresets
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('1week')}>
|
||||||
|
1주일
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('1month')}>
|
||||||
|
1개월
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('3months')}>
|
||||||
|
3개월
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" onClick={handleSearch}>
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row2: 거래처 검색 */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filters.vendorSearch}
|
||||||
|
onChange={(e) => updateFilter('vendorSearch', e.target.value)}
|
||||||
|
placeholder="사업자 번호 또는 사업자명"
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row3: 상태 + 정렬 */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onValueChange={(v) => updateFilter('status', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.sortBy}
|
||||||
|
onValueChange={(v) => updateFilter('sortBy', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_BY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.sortOrder}
|
||||||
|
onValueChange={(v) => updateFilter('sortOrder', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_ORDER_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={records.length > 0 && selectedIds.size === records.length}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(records.map((r) => r.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center">번호</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">발행번호</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">공급받는자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">작성일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">전송일자</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">세액</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">합계금액</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.length > 0 ? (
|
||||||
|
records.map((record, index) => {
|
||||||
|
const statusInfo = TAX_INVOICE_STATUS_MAP[record.status];
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={record.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => router.push(`?mode=edit&id=${record.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(record.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.add(record.id);
|
||||||
|
else next.delete(record.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{record.invoiceNumber}</TableCell>
|
||||||
|
<TableCell>{record.vendorName}</TableCell>
|
||||||
|
<TableCell className="text-center">{record.writeDate}</TableCell>
|
||||||
|
<TableCell className="text-center">{record.sendDate || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{record.supplyAmount.toLocaleString('ko-KR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{record.taxAmount.toLocaleString('ko-KR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{record.totalAmount.toLocaleString('ko-KR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10}>
|
||||||
|
<EmptyState
|
||||||
|
message="해당 조건에 맞는 세금계산서가 없습니다."
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 공급자 설정 모달 */}
|
||||||
|
<SupplierSettingModal
|
||||||
|
open={showSupplierModal}
|
||||||
|
onOpenChange={setShowSupplierModal}
|
||||||
|
settings={supplierSettings}
|
||||||
|
onSaved={setSupplierSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 거래처 검색 모달 */}
|
||||||
|
<SearchableSelectionModal<VendorSearchItem>
|
||||||
|
open={showVendorSearch}
|
||||||
|
onOpenChange={setShowVendorSearch}
|
||||||
|
title="거래처 검색"
|
||||||
|
searchPlaceholder="거래처명, 사업자번호, 담당자명"
|
||||||
|
fetchData={searchVendorsForTaxInvoice}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
mode="single"
|
||||||
|
onSelect={handleVendorSelect}
|
||||||
|
searchMode="enter"
|
||||||
|
dialogClassName="sm:max-w-xl"
|
||||||
|
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
|
||||||
|
noResultMessage="거래처가 없습니다."
|
||||||
|
emptyQueryMessage="거래처명 또는 사업자번호를 입력하세요."
|
||||||
|
listWrapper={(children) => (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-2 border-b border-gray-300 bg-gray-100 font-semibold text-sm text-center">
|
||||||
|
<div className="px-4 py-2 border-r border-gray-300">거래처명</div>
|
||||||
|
<div className="px-4 py-2">사업자번호</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<div className="grid grid-cols-2 border-b border-gray-200 hover:bg-muted/50 text-sm">
|
||||||
|
<div className="px-4 py-2.5 border-r border-gray-200 text-center">{item.companyName}</div>
|
||||||
|
<div className="px-4 py-2.5 text-center">{item.businessNumber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/accounting/TaxInvoiceIssuance/types.ts
Normal file
155
src/components/accounting/TaxInvoiceIssuance/types.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 세금계산서 발행 - 타입 및 상수 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 세금계산서 상태 =====
|
||||||
|
export type TaxInvoiceStatus = 'draft' | 'issued' | 'nts_sent' | 'error';
|
||||||
|
|
||||||
|
export const TAX_INVOICE_STATUS_MAP: Record<TaxInvoiceStatus, { label: string; color: string }> = {
|
||||||
|
draft: { label: '작성중', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
issued: { label: '발행완료', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
nts_sent: { label: '국세청전송', color: 'bg-green-100 text-green-700' },
|
||||||
|
error: { label: '오류', color: 'bg-red-100 text-red-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 사업자 정보 (공급자/공급받는자 공통) =====
|
||||||
|
export interface BusinessEntity {
|
||||||
|
businessNumber: string;
|
||||||
|
companyName: string;
|
||||||
|
representativeName: string;
|
||||||
|
address: string;
|
||||||
|
businessType: string;
|
||||||
|
businessItem: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
contactEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 품목 행 =====
|
||||||
|
export interface TaxInvoiceItem {
|
||||||
|
id: string;
|
||||||
|
month: string;
|
||||||
|
day: string;
|
||||||
|
itemName: string;
|
||||||
|
specification: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
supplyAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
taxType: 'taxable' | 'taxFree';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 목록용 레코드 =====
|
||||||
|
export interface TaxInvoiceRecord {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
vendorName: string;
|
||||||
|
vendorBusinessNumber: string;
|
||||||
|
writeDate: string;
|
||||||
|
sendDate: string | null;
|
||||||
|
supplyAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
status: TaxInvoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 발행 폼 데이터 =====
|
||||||
|
export interface TaxInvoiceFormData {
|
||||||
|
supplier: BusinessEntity;
|
||||||
|
receiver: BusinessEntity;
|
||||||
|
writeDate: string;
|
||||||
|
items: TaxInvoiceItem[];
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 공급자 설정 =====
|
||||||
|
export type SupplierSettings = BusinessEntity;
|
||||||
|
|
||||||
|
// ===== 거래처 검색 결과 =====
|
||||||
|
export interface VendorSearchItem {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
businessNumber: string;
|
||||||
|
representativeName: string;
|
||||||
|
address: string;
|
||||||
|
businessType: string;
|
||||||
|
businessItem: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
contactEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 상태 =====
|
||||||
|
export interface FilterState {
|
||||||
|
dateType: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
vendorSearch: string;
|
||||||
|
status: string;
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Select 옵션 상수 =====
|
||||||
|
export const DATE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'write', label: '작성일자' },
|
||||||
|
{ value: 'send', label: '전송일자' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'draft', label: '작성중' },
|
||||||
|
{ value: 'issued', label: '발행완료' },
|
||||||
|
{ value: 'nts_sent', label: '국세청전송' },
|
||||||
|
{ value: 'error', label: '오류' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SORT_BY_OPTIONS = [
|
||||||
|
{ value: 'writeDate', label: '작성일자' },
|
||||||
|
{ value: 'sendDate', label: '전송일자' },
|
||||||
|
{ value: 'totalAmount', label: '합계금액' },
|
||||||
|
{ value: 'vendorName', label: '거래처명' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SORT_ORDER_OPTIONS = [
|
||||||
|
{ value: 'desc', label: '내림차순' },
|
||||||
|
{ value: 'asc', label: '오름차순' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAX_TYPE_OPTIONS = [
|
||||||
|
{ value: 'taxable', label: '과세' },
|
||||||
|
{ value: 'taxFree', label: '면세' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 빈 품목 행 생성 =====
|
||||||
|
export function createEmptyItem(): TaxInvoiceItem {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
month: '',
|
||||||
|
day: '',
|
||||||
|
itemName: '',
|
||||||
|
specification: '',
|
||||||
|
quantity: 0,
|
||||||
|
unitPrice: 0,
|
||||||
|
supplyAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
taxType: 'taxable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 빈 사업자 정보 =====
|
||||||
|
export function createEmptyBusinessEntity(): BusinessEntity {
|
||||||
|
return {
|
||||||
|
businessNumber: '',
|
||||||
|
companyName: '',
|
||||||
|
representativeName: '',
|
||||||
|
address: '',
|
||||||
|
businessType: '',
|
||||||
|
businessItem: '',
|
||||||
|
contactName: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactEmail: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 내역 불러오기 팝업 (팝업 in 팝업)
|
||||||
|
*
|
||||||
|
* - ManualEntryModal 위에 z-index로 표시
|
||||||
|
* - 날짜범위 + 가맹점/승인번호 검색 + 조회
|
||||||
|
* - 빈 상태: "카드 내역이 없습니다."
|
||||||
|
* - 테이블: 날짜, 가맹점, 금액, 승인번호, 선택 버튼
|
||||||
|
* - 선택 시 부모에 데이터 전달 후 닫기
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { getCardHistory } from './actions';
|
||||||
|
import type { CardHistoryRecord } from './types';
|
||||||
|
|
||||||
|
interface CardHistoryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelect: (record: CardHistoryRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHistoryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSelect,
|
||||||
|
}: CardHistoryModalProps) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const [startDate, setStartDate] = useState(monthAgo);
|
||||||
|
const [endDate, setEndDate] = useState(today);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [data, setData] = useState<CardHistoryRecord[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasSearched(true);
|
||||||
|
try {
|
||||||
|
const result = await getCardHistory({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
search: searchText,
|
||||||
|
page: 1,
|
||||||
|
perPage: 50,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data ?? []);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '카드 내역 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, searchText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>카드 내역 불러오기</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Row1: 날짜 범위 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DatePicker
|
||||||
|
value={startDate}
|
||||||
|
onChange={setStartDate}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">~</span>
|
||||||
|
<DatePicker
|
||||||
|
value={endDate}
|
||||||
|
onChange={setEndDate}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Row2: 검색어 + 조회 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="가맹점/승인번호"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="flex-1 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={handleSearch} disabled={isLoading}>
|
||||||
|
<Search className="h-4 w-4 mr-1" />
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md">
|
||||||
|
{!hasSearched ? (
|
||||||
|
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground">
|
||||||
|
조회 버튼을 클릭하여 카드 내역을 검색하세요.
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground">
|
||||||
|
조회 중...
|
||||||
|
</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground">
|
||||||
|
카드 내역이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-center">날짜</TableHead>
|
||||||
|
<TableHead>가맹점</TableHead>
|
||||||
|
<TableHead className="text-right">금액</TableHead>
|
||||||
|
<TableHead className="text-center">승인번호</TableHead>
|
||||||
|
<TableHead className="text-center w-[70px]">선택</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center text-sm">
|
||||||
|
{item.transactionDate}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.merchantName}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium">
|
||||||
|
{item.amount.toLocaleString()}원
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
|
{item.approvalNumber}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분개 수정 팝업
|
||||||
|
*
|
||||||
|
* - 세금계산서 정보 (읽기전용): 구분, 거래처, 공급가액, 세액
|
||||||
|
* - 분개 내역 테이블: 구분(차변/대변), 계정과목 Select, 차변 금액, 대변 금액
|
||||||
|
* - 동적 행 추가/삭제 + 합계 행
|
||||||
|
* - 버튼: 분개 삭제, 취소, 분개 수정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableFooter,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
getJournalEntries,
|
||||||
|
updateJournalEntry,
|
||||||
|
deleteJournalEntry,
|
||||||
|
} from './actions';
|
||||||
|
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
|
||||||
|
import {
|
||||||
|
TAB_OPTIONS,
|
||||||
|
JOURNAL_SIDE_OPTIONS,
|
||||||
|
ACCOUNT_SUBJECT_OPTIONS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface JournalEntryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
invoice: TaxInvoiceMgmtRecord;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyRow(): JournalEntryRow {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
side: 'debit',
|
||||||
|
accountSubject: '',
|
||||||
|
debitAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalEntryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
invoice,
|
||||||
|
onSuccess,
|
||||||
|
}: JournalEntryModalProps) {
|
||||||
|
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// 기존 분개 내역 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !invoice) return;
|
||||||
|
|
||||||
|
const loadEntries = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getJournalEntries(invoice.id);
|
||||||
|
if (result.success && result.data && result.data.rows.length > 0) {
|
||||||
|
setRows(
|
||||||
|
result.data.rows.map((r) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
side: r.side as JournalSide,
|
||||||
|
accountSubject: r.account_subject,
|
||||||
|
debitAmount: r.debit_amount,
|
||||||
|
creditAmount: r.credit_amount,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 기본 행 2개 (차변/대변)
|
||||||
|
setRows([
|
||||||
|
{ ...createEmptyRow(), side: 'debit', debitAmount: invoice.totalAmount },
|
||||||
|
{ ...createEmptyRow(), side: 'credit', creditAmount: invoice.totalAmount },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRows([
|
||||||
|
{ ...createEmptyRow(), side: 'debit', debitAmount: invoice.totalAmount },
|
||||||
|
{ ...createEmptyRow(), side: 'credit', creditAmount: invoice.totalAmount },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEntries();
|
||||||
|
}, [open, invoice]);
|
||||||
|
|
||||||
|
// 행 추가
|
||||||
|
const handleAddRow = useCallback(() => {
|
||||||
|
setRows((prev) => [...prev, createEmptyRow()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 삭제
|
||||||
|
const handleRemoveRow = useCallback((rowId: string) => {
|
||||||
|
setRows((prev) => {
|
||||||
|
if (prev.length <= 1) return prev;
|
||||||
|
return prev.filter((r) => r.id !== rowId);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 행 수정
|
||||||
|
const handleRowChange = useCallback(
|
||||||
|
(rowId: string, field: keyof JournalEntryRow, value: string | number) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => {
|
||||||
|
if (r.id !== rowId) return r;
|
||||||
|
const updated = { ...r, [field]: value };
|
||||||
|
// 구분 변경 시 금액 초기화
|
||||||
|
if (field === 'side') {
|
||||||
|
if (value === 'debit') {
|
||||||
|
updated.creditAmount = 0;
|
||||||
|
} else {
|
||||||
|
updated.debitAmount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const debitTotal = rows.reduce((sum, r) => sum + (r.debitAmount || 0), 0);
|
||||||
|
const creditTotal = rows.reduce((sum, r) => sum + (r.creditAmount || 0), 0);
|
||||||
|
return { debitTotal, creditTotal };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// 분개 수정 저장
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
const hasEmptyAccount = rows.some((r) => !r.accountSubject);
|
||||||
|
if (hasEmptyAccount) {
|
||||||
|
toast.warning('모든 행의 계정과목을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.debitTotal !== totals.creditTotal) {
|
||||||
|
toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateJournalEntry(invoice.id, rows);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('분개가 수정되었습니다.');
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '분개 수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [invoice.id, rows, totals, onSuccess]);
|
||||||
|
|
||||||
|
// 분개 삭제
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
try {
|
||||||
|
const result = await deleteJournalEntry(invoice.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('분개가 삭제되었습니다.');
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '분개 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}, [invoice.id, onSuccess]);
|
||||||
|
|
||||||
|
const divisionLabel =
|
||||||
|
TAB_OPTIONS.find((t) => t.value === invoice.division)?.label || invoice.division;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>분개 수정</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 세금계산서 정보 (읽기전용) */}
|
||||||
|
<div className="space-y-2 p-3 border rounded-lg">
|
||||||
|
<Label className="text-sm font-semibold">세금계산서 정보</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">구분</Label>
|
||||||
|
<div className="font-medium">{divisionLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||||
|
<div className="font-medium">{invoice.vendorName}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">공급가액</Label>
|
||||||
|
<div className="font-medium">{invoice.supplyAmount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">세액</Label>
|
||||||
|
<div className="font-medium">{invoice.taxAmount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분개 내역 */}
|
||||||
|
<div className="space-y-2 p-3 border rounded-lg flex-1 overflow-auto">
|
||||||
|
<Label className="text-sm font-semibold">분개 내역</Label>
|
||||||
|
|
||||||
|
<div className="overflow-auto border rounded-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[150px] text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-center w-[100px]">구분</TableHead>
|
||||||
|
<TableHead>계정과목</TableHead>
|
||||||
|
<TableHead className="text-right w-[140px]">차변 금액</TableHead>
|
||||||
|
<TableHead className="text-right w-[140px]">대변 금액</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.side}
|
||||||
|
onValueChange={(v) => handleRowChange(row.id, 'side', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{JOURNAL_SIDE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Select
|
||||||
|
value={row.accountSubject}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleRowChange(row.id, 'accountSubject', v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
|
||||||
|
(opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.debitAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(
|
||||||
|
row.id,
|
||||||
|
'debitAmount',
|
||||||
|
Number(e.target.value) || 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'credit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={row.creditAmount || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRowChange(
|
||||||
|
row.id,
|
||||||
|
'creditAmount',
|
||||||
|
Number(e.target.value) || 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={row.side === 'debit'}
|
||||||
|
className="h-8 text-sm text-right"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
|
<TableCell colSpan={2} className="text-right text-sm">
|
||||||
|
합계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.debitTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-bold">
|
||||||
|
{totals.creditTotal.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차대변 불일치 경고 */}
|
||||||
|
{totals.debitTotal !== totals.creditTotal && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
차변 합계({totals.debitTotal.toLocaleString()})와 대변 합계(
|
||||||
|
{totals.creditTotal.toLocaleString()})가 일치하지 않습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="mr-auto"
|
||||||
|
>
|
||||||
|
분개 삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? '저장 중...' : '분개 수정'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 분개 삭제 확인 */}
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>분개 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 세금계산서의 분개 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수기 입력 팝업
|
||||||
|
*
|
||||||
|
* - 구분 Select (매출/매입)
|
||||||
|
* - 작성일자 DatePicker
|
||||||
|
* - 공급자 정보: "카드 내역 불러오기" 버튼 + 공급자명/사업자번호
|
||||||
|
* - 공급가액/세액/합계 (합계 자동계산)
|
||||||
|
* - 품목/과세유형/비고
|
||||||
|
* - 취소/저장 버튼
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { CreditCard } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { createTaxInvoice } from './actions';
|
||||||
|
import { CardHistoryModal } from './CardHistoryModal';
|
||||||
|
import type { InvoiceTab, TaxType, ManualEntryFormData, CardHistoryRecord } from './types';
|
||||||
|
import { DIVISION_OPTIONS, TAX_TYPE_OPTIONS } from './types';
|
||||||
|
|
||||||
|
interface ManualEntryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
defaultDivision?: InvoiceTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: ManualEntryFormData = {
|
||||||
|
division: 'sales',
|
||||||
|
writeDate: new Date().toISOString().split('T')[0],
|
||||||
|
vendorName: '',
|
||||||
|
vendorBusinessNumber: '',
|
||||||
|
supplyAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
itemName: '',
|
||||||
|
taxType: 'taxable',
|
||||||
|
memo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManualEntryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
defaultDivision = 'sales',
|
||||||
|
}: ManualEntryModalProps) {
|
||||||
|
const [formData, setFormData] = useState<ManualEntryFormData>({
|
||||||
|
...initialFormData,
|
||||||
|
division: defaultDivision,
|
||||||
|
});
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showCardHistory, setShowCardHistory] = useState(false);
|
||||||
|
|
||||||
|
// 모달 열릴 때 폼 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormData({
|
||||||
|
...initialFormData,
|
||||||
|
division: defaultDivision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, defaultDivision]);
|
||||||
|
|
||||||
|
// 공급가액/세액 변경 시 합계 자동 계산
|
||||||
|
const handleAmountChange = useCallback(
|
||||||
|
(field: 'supplyAmount' | 'taxAmount', value: string) => {
|
||||||
|
const numValue = Number(value) || 0;
|
||||||
|
setFormData((prev) => {
|
||||||
|
const updated = { ...prev, [field]: numValue };
|
||||||
|
updated.totalAmount = updated.supplyAmount + updated.taxAmount;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 필드 변경
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof ManualEntryFormData, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 카드 내역 선택 시
|
||||||
|
const handleCardSelect = useCallback((record: CardHistoryRecord) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
vendorName: record.merchantName,
|
||||||
|
vendorBusinessNumber: record.businessNumber,
|
||||||
|
supplyAmount: Math.round(record.amount / 1.1),
|
||||||
|
taxAmount: record.amount - Math.round(record.amount / 1.1),
|
||||||
|
totalAmount: record.amount,
|
||||||
|
}));
|
||||||
|
setShowCardHistory(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!formData.vendorName.trim()) {
|
||||||
|
toast.warning('공급자명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.supplyAmount <= 0) {
|
||||||
|
toast.warning('공급가액을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await createTaxInvoice(formData);
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [formData, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>세금계산서 수기 입력</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 구분 + 작성일자 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">구분</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.division}
|
||||||
|
onValueChange={(v) => handleChange('division', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DIVISION_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">작성일자</Label>
|
||||||
|
<DatePicker
|
||||||
|
value={formData.writeDate}
|
||||||
|
onChange={(date) => handleChange('writeDate', date)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공급자 정보 */}
|
||||||
|
<div className="space-y-2 p-3 border rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">공급자 정보</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCardHistory(true)}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4 mr-1" />
|
||||||
|
카드 내역 불러오기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
label="공급자명"
|
||||||
|
value={formData.vendorName}
|
||||||
|
onChange={(value) => handleChange('vendorName', value)}
|
||||||
|
placeholder="공급자명"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="사업자번호"
|
||||||
|
value={formData.vendorBusinessNumber}
|
||||||
|
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
||||||
|
placeholder="사업자번호"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 금액 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<FormField
|
||||||
|
label="공급가액"
|
||||||
|
type="number"
|
||||||
|
value={formData.supplyAmount || ''}
|
||||||
|
onChange={(value) => handleAmountChange('supplyAmount', value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="세액"
|
||||||
|
type="number"
|
||||||
|
value={formData.taxAmount || ''}
|
||||||
|
onChange={(value) => handleAmountChange('taxAmount', value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="합계"
|
||||||
|
type="number"
|
||||||
|
value={formData.totalAmount || ''}
|
||||||
|
disabled
|
||||||
|
inputClassName="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 + 과세유형 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="품목"
|
||||||
|
value={formData.itemName}
|
||||||
|
onChange={(value) => handleChange('itemName', value)}
|
||||||
|
placeholder="품목"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">과세유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.taxType}
|
||||||
|
onValueChange={(v) => handleChange('taxType', v as TaxType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TAX_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(value) => handleChange('memo', value)}
|
||||||
|
placeholder="비고"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 카드 내역 불러오기 팝업 (팝업 in 팝업) */}
|
||||||
|
<CardHistoryModal
|
||||||
|
open={showCardHistory}
|
||||||
|
onOpenChange={setShowCardHistory}
|
||||||
|
onSelect={handleCardSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/accounting/TaxInvoiceManagement/actions.ts
Normal file
187
src/components/accounting/TaxInvoiceManagement/actions.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
import type {
|
||||||
|
TaxInvoiceMgmtRecord,
|
||||||
|
TaxInvoiceMgmtApiData,
|
||||||
|
TaxInvoiceSummary,
|
||||||
|
TaxInvoiceSummaryApiData,
|
||||||
|
CardHistoryRecord,
|
||||||
|
CardHistoryApiData,
|
||||||
|
ManualEntryFormData,
|
||||||
|
JournalEntryRow,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
transformApiToFrontend,
|
||||||
|
transformFrontendToApi,
|
||||||
|
transformCardHistoryApi,
|
||||||
|
transformSummaryApi,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 세금계산서 목록 Mock =====
|
||||||
|
// TODO: 실제 API 연동 시 Mock 제거
|
||||||
|
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
|
||||||
|
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
|
||||||
|
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
|
||||||
|
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
|
||||||
|
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
|
||||||
|
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
|
||||||
|
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 세금계산서 목록 조회 =====
|
||||||
|
export async function getTaxInvoices(params: {
|
||||||
|
division?: string;
|
||||||
|
dateType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
vendorSearch?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
}) {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
|
||||||
|
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
|
||||||
|
// transform: transformApiToFrontend,
|
||||||
|
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
data: filtered,
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 세금계산서 요약 조회 =====
|
||||||
|
export async function getTaxInvoiceSummary(_params: {
|
||||||
|
dateType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
vendorSearch?: string;
|
||||||
|
}): Promise<ActionResult<TaxInvoiceSummary>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executeServerAction({ ... });
|
||||||
|
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
|
||||||
|
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
|
||||||
|
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
|
||||||
|
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
|
||||||
|
salesCount: sales.length,
|
||||||
|
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
|
||||||
|
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
|
||||||
|
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
|
||||||
|
purchaseCount: purchase.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 세금계산서 수기 등록 =====
|
||||||
|
export async function createTaxInvoice(
|
||||||
|
data: ManualEntryFormData
|
||||||
|
): Promise<ActionResult<TaxInvoiceMgmtRecord>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/tax-invoices'),
|
||||||
|
method: 'POST',
|
||||||
|
body: transformFrontendToApi(data),
|
||||||
|
transform: (d: TaxInvoiceMgmtApiData) => transformApiToFrontend(d),
|
||||||
|
errorMessage: '세금계산서 등록에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카드 내역 조회 =====
|
||||||
|
// TODO: 실제 API 연동 시 Mock 제거
|
||||||
|
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
|
||||||
|
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
|
||||||
|
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
|
||||||
|
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
|
||||||
|
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
|
||||||
|
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getCardHistory(_params: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
}): Promise<ActionResult<CardHistoryRecord[]>> {
|
||||||
|
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||||
|
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
|
||||||
|
// url: buildApiUrl('/api/v1/card-transactions/history', {
|
||||||
|
// start_date: _params.startDate,
|
||||||
|
// end_date: _params.endDate,
|
||||||
|
// search: _params.search || undefined,
|
||||||
|
// page: _params.page,
|
||||||
|
// per_page: _params.perPage,
|
||||||
|
// }),
|
||||||
|
// transform: transformCardHistoryApi,
|
||||||
|
// errorMessage: '카드 내역 조회에 실패했습니다.',
|
||||||
|
// });
|
||||||
|
return { success: true, data: MOCK_CARD_HISTORY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 내역 조회 =====
|
||||||
|
export async function getJournalEntries(invoiceId: string): Promise<ActionResult<{
|
||||||
|
rows: { id: string; side: string; account_subject: string; debit_amount: number; credit_amount: number }[];
|
||||||
|
}>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/tax-invoices/${invoiceId}/journal-entries`),
|
||||||
|
errorMessage: '분개 내역 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 수정 =====
|
||||||
|
export async function updateJournalEntry(
|
||||||
|
invoiceId: string,
|
||||||
|
rows: JournalEntryRow[]
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/tax-invoices/${invoiceId}/journal-entries`),
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
rows: rows.map((r) => ({
|
||||||
|
side: r.side,
|
||||||
|
account_subject: r.accountSubject,
|
||||||
|
debit_amount: r.debitAmount,
|
||||||
|
credit_amount: r.creditAmount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
errorMessage: '분개 수정에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 삭제 =====
|
||||||
|
export async function deleteJournalEntry(invoiceId: string): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/tax-invoices/${invoiceId}/journal-entries`),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '분개 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 엑셀 다운로드 =====
|
||||||
|
export async function downloadTaxInvoiceExcel(params: {
|
||||||
|
division?: string;
|
||||||
|
dateType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
vendorSearch?: string;
|
||||||
|
}): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/tax-invoices/export', {
|
||||||
|
division: params.division,
|
||||||
|
date_type: params.dateType,
|
||||||
|
start_date: params.startDate,
|
||||||
|
end_date: params.endDate,
|
||||||
|
vendor_search: params.vendorSearch || undefined,
|
||||||
|
}),
|
||||||
|
errorMessage: '엑셀 다운로드에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
551
src/components/accounting/TaxInvoiceManagement/index.tsx
Normal file
551
src/components/accounting/TaxInvoiceManagement/index.tsx
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 관리 - 메인 리스트 페이지
|
||||||
|
*
|
||||||
|
* - 매출/매입 탭 (카운트 표시)
|
||||||
|
* - 일자유형 Select + 날짜범위 + 분기버튼 + 거래처 검색
|
||||||
|
* - 요약 카드 (매출/매입 공급가액/세액/합계)
|
||||||
|
* - 테이블 + 범례 + 기간 요약
|
||||||
|
* - 분개 버튼 → JournalEntryModal
|
||||||
|
* - 수기 입력 버튼 → ManualEntryModal
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
PenLine,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { StatCards } from '@/components/organisms/StatCards';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
UniversalListPage,
|
||||||
|
type UniversalListConfig,
|
||||||
|
type SelectionHandlers,
|
||||||
|
type RowClickHandlers,
|
||||||
|
} from '@/components/templates/UniversalListPage';
|
||||||
|
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||||
|
import {
|
||||||
|
getTaxInvoices,
|
||||||
|
getTaxInvoiceSummary,
|
||||||
|
downloadTaxInvoiceExcel,
|
||||||
|
} from './actions';
|
||||||
|
import { ManualEntryModal } from './ManualEntryModal';
|
||||||
|
import { JournalEntryModal } from './JournalEntryModal';
|
||||||
|
import type {
|
||||||
|
TaxInvoiceMgmtRecord,
|
||||||
|
InvoiceTab,
|
||||||
|
TaxInvoiceSummary,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
TAB_OPTIONS,
|
||||||
|
DATE_TYPE_OPTIONS,
|
||||||
|
TAX_TYPE_LABELS,
|
||||||
|
RECEIPT_TYPE_LABELS,
|
||||||
|
INVOICE_STATUS_MAP,
|
||||||
|
INVOICE_SOURCE_LABELS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 분기 옵션 =====
|
||||||
|
const QUARTER_BUTTONS = [
|
||||||
|
{ value: 'Q1', label: '1분기', startMonth: 1, endMonth: 3 },
|
||||||
|
{ value: 'Q2', label: '2분기', startMonth: 4, endMonth: 6 },
|
||||||
|
{ value: 'Q3', label: '3분기', startMonth: 7, endMonth: 9 },
|
||||||
|
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 테이블 컬럼 =====
|
||||||
|
const tableColumns = [
|
||||||
|
{ key: 'writeDate', label: '작성일자', className: 'text-center' },
|
||||||
|
{ key: 'issueDate', label: '발급일자', className: 'text-center' },
|
||||||
|
{ key: 'vendorName', label: '거래처' },
|
||||||
|
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center' },
|
||||||
|
{ key: 'taxType', label: '과세형태', className: 'text-center' },
|
||||||
|
{ key: 'itemName', label: '품목' },
|
||||||
|
{ key: 'supplyAmount', label: '공급가액', className: 'text-right' },
|
||||||
|
{ key: 'taxAmount', label: '세액', className: 'text-right' },
|
||||||
|
{ key: 'totalAmount', label: '합계', className: 'text-right' },
|
||||||
|
{ key: 'receiptType', label: '영수청구', className: 'text-center' },
|
||||||
|
{ key: 'documentType', label: '문서형태', className: 'text-center' },
|
||||||
|
{ key: 'issueType', label: '발급형태', className: 'text-center' },
|
||||||
|
{ key: 'status', label: '상태', className: 'text-center' },
|
||||||
|
{ key: 'journal', label: '분개', className: 'text-center w-[80px]' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 날짜 헬퍼 =====
|
||||||
|
function getQuarterDates(year: number, quarter: string) {
|
||||||
|
const q = QUARTER_BUTTONS.find((b) => b.value === quarter);
|
||||||
|
if (!q) return { start: '', end: '' };
|
||||||
|
const start = `${year}-${String(q.startMonth).padStart(2, '0')}-01`;
|
||||||
|
const lastDay = new Date(year, q.endMonth, 0).getDate();
|
||||||
|
const end = `${year}-${String(q.endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentQuarter(): string {
|
||||||
|
const month = new Date().getMonth() + 1;
|
||||||
|
if (month <= 3) return 'Q1';
|
||||||
|
if (month <= 6) return 'Q2';
|
||||||
|
if (month <= 9) return 'Q3';
|
||||||
|
return 'Q4';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxInvoiceManagement() {
|
||||||
|
// ===== 필터 상태 =====
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [activeTab, setActiveTab] = useState<InvoiceTab>('sales');
|
||||||
|
const [dateType, setDateType] = useState('write_date');
|
||||||
|
const [selectedQuarter, setSelectedQuarter] = useState(getCurrentQuarter());
|
||||||
|
const [startDate, setStartDate] = useState(() => {
|
||||||
|
const q = getQuarterDates(currentYear, getCurrentQuarter());
|
||||||
|
return q.start;
|
||||||
|
});
|
||||||
|
const [endDate, setEndDate] = useState(() => {
|
||||||
|
const q = getQuarterDates(currentYear, getCurrentQuarter());
|
||||||
|
return q.end;
|
||||||
|
});
|
||||||
|
const [vendorSearch, setVendorSearch] = useState('');
|
||||||
|
|
||||||
|
// ===== 데이터 상태 =====
|
||||||
|
const [invoiceData, setInvoiceData] = useState<TaxInvoiceMgmtRecord[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const isInitialLoadDone = useRef(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
perPage: 20,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 요약 상태 =====
|
||||||
|
const [summary, setSummary] = useState<TaxInvoiceSummary>({
|
||||||
|
salesSupplyAmount: 0,
|
||||||
|
salesTaxAmount: 0,
|
||||||
|
salesTotalAmount: 0,
|
||||||
|
salesCount: 0,
|
||||||
|
purchaseSupplyAmount: 0,
|
||||||
|
purchaseTaxAmount: 0,
|
||||||
|
purchaseTotalAmount: 0,
|
||||||
|
purchaseCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 모달 상태 =====
|
||||||
|
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||||
|
const [journalTarget, setJournalTarget] = useState<TaxInvoiceMgmtRecord | null>(null);
|
||||||
|
|
||||||
|
// ===== 분기 버튼 클릭 =====
|
||||||
|
const handleQuarterClick = useCallback((quarter: string) => {
|
||||||
|
setSelectedQuarter(quarter);
|
||||||
|
const dates = getQuarterDates(currentYear, quarter);
|
||||||
|
setStartDate(dates.start);
|
||||||
|
setEndDate(dates.end);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [currentYear]);
|
||||||
|
|
||||||
|
// ===== 데이터 로드 =====
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!isInitialLoadDone.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [listResult, summaryResult] = await Promise.all([
|
||||||
|
getTaxInvoices({
|
||||||
|
division: activeTab,
|
||||||
|
dateType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
vendorSearch,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: 20,
|
||||||
|
}),
|
||||||
|
getTaxInvoiceSummary({
|
||||||
|
dateType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
vendorSearch,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success) {
|
||||||
|
setInvoiceData(listResult.data);
|
||||||
|
setPagination(listResult.pagination);
|
||||||
|
} else {
|
||||||
|
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryResult.success && summaryResult.data) {
|
||||||
|
setSummary(summaryResult.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
isInitialLoadDone.current = true;
|
||||||
|
}
|
||||||
|
}, [activeTab, dateType, startDate, endDate, vendorSearch, currentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 탭 변경 =====
|
||||||
|
const handleTabChange = useCallback((tab: InvoiceTab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ===== 조회 버튼 =====
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 엑셀 다운로드 =====
|
||||||
|
const handleExcelDownload = useCallback(async () => {
|
||||||
|
const result = await downloadTaxInvoiceExcel({
|
||||||
|
division: activeTab,
|
||||||
|
dateType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
vendorSearch,
|
||||||
|
});
|
||||||
|
if (result.success && result.data) {
|
||||||
|
window.open(result.data.url, '_blank');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [activeTab, dateType, startDate, endDate, vendorSearch]);
|
||||||
|
|
||||||
|
// ===== 수기 등록 완료 =====
|
||||||
|
const handleManualEntrySuccess = useCallback(() => {
|
||||||
|
setShowManualEntry(false);
|
||||||
|
loadData();
|
||||||
|
toast.success('세금계산서가 등록되었습니다.');
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 분개 완료 =====
|
||||||
|
const handleJournalSuccess = useCallback(() => {
|
||||||
|
setJournalTarget(null);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ===== 기간 요약 계산 =====
|
||||||
|
const periodDifference = useMemo(() => {
|
||||||
|
return summary.salesTotalAmount - summary.purchaseTotalAmount;
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
// ===== UniversalListPage Config =====
|
||||||
|
const config: UniversalListConfig<TaxInvoiceMgmtRecord> = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: '세금계산서 관리',
|
||||||
|
description: '홈택스에 신고된 세금계산서 매입/매출 내역을 조회하고 관리합니다',
|
||||||
|
icon: FileText,
|
||||||
|
basePath: '/accounting/tax-invoices',
|
||||||
|
|
||||||
|
idField: 'id',
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
getList: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: invoiceData,
|
||||||
|
totalCount: pagination.total,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: tableColumns,
|
||||||
|
clientSideFiltering: false,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
hideSearch: true,
|
||||||
|
showCheckbox: false,
|
||||||
|
|
||||||
|
// ===== 검색 영역 (beforeTableContent) =====
|
||||||
|
beforeTableContent: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 검색 필터 카드 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
{/* Row1: 일자타입 + 날짜범위 + 분기 버튼 + 조회 */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||||
|
<Select value={dateType} onValueChange={setDateType}>
|
||||||
|
<SelectTrigger className="w-full lg:w-[120px] h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
hidePresets
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{QUARTER_BUTTONS.map((q) => (
|
||||||
|
<Button
|
||||||
|
key={q.value}
|
||||||
|
size="sm"
|
||||||
|
variant={selectedQuarter === q.value ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleQuarterClick(q.value)}
|
||||||
|
>
|
||||||
|
{q.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" onClick={handleSearch}>
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row2: 거래처 검색 (아이콘 포함) */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={vendorSearch}
|
||||||
|
onChange={(e) => setVendorSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="사업자 번호 또는 사업자명"
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 요약 카드 5개 */}
|
||||||
|
<StatCards
|
||||||
|
stats={[
|
||||||
|
{ label: '매출 공급가액', value: `${summary.salesSupplyAmount.toLocaleString()}원` },
|
||||||
|
{ label: '매출 세액', value: `${summary.salesTaxAmount.toLocaleString()}원` },
|
||||||
|
{ label: '매입 과세 공급가액', value: `${summary.purchaseSupplyAmount.toLocaleString()}원` },
|
||||||
|
{ label: '매입 면세 공급가액', value: '0원' },
|
||||||
|
{ label: '매입 세액', value: `${summary.purchaseTaxAmount.toLocaleString()}원` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 매출/매입 탭 + 액션 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TAB_OPTIONS.map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t.value}
|
||||||
|
size="sm"
|
||||||
|
variant={activeTab === t.value ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleTabChange(t.value)}
|
||||||
|
>
|
||||||
|
{t.label} {t.value === 'sales' ? summary.salesCount : summary.purchaseCount}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setShowManualEntry(true)}>
|
||||||
|
<PenLine className="h-4 w-4 mr-1" />
|
||||||
|
수기 입력
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
// 탭은 beforeTableContent에서 수동 렌더링 (카드와 테이블 사이)
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 =====
|
||||||
|
renderTableRow: (
|
||||||
|
item: TaxInvoiceMgmtRecord,
|
||||||
|
_index: number,
|
||||||
|
_globalIndex: number,
|
||||||
|
_handlers: SelectionHandlers & RowClickHandlers<TaxInvoiceMgmtRecord>
|
||||||
|
) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={`hover:bg-muted/50 ${item.source === 'manual' ? 'bg-yellow-50/50' : ''}`}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-sm">{item.writeDate}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">{item.issueDate || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.vendorName}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">{item.vendorBusinessNumber}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{TAX_TYPE_LABELS[item.taxType]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.itemName || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{item.supplyAmount.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{item.taxAmount.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium">{item.totalAmount.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">
|
||||||
|
{RECEIPT_TYPE_LABELS[item.receiptType]}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">{item.documentNumber || '-'}</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">{INVOICE_SOURCE_LABELS[item.source]}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={`text-xs ${INVOICE_STATUS_MAP[item.status].color}`}>
|
||||||
|
{INVOICE_STATUS_MAP[item.status].label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setJournalTarget(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
분개
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
renderMobileCard: (
|
||||||
|
item: TaxInvoiceMgmtRecord,
|
||||||
|
_index: number,
|
||||||
|
_globalIndex: number,
|
||||||
|
_handlers: SelectionHandlers & RowClickHandlers<TaxInvoiceMgmtRecord>
|
||||||
|
) => (
|
||||||
|
<MobileCard
|
||||||
|
key={item.id}
|
||||||
|
title={item.vendorName}
|
||||||
|
subtitle={item.documentNumber || item.writeDate}
|
||||||
|
badge={INVOICE_STATUS_MAP[item.status].label}
|
||||||
|
badgeVariant="outline"
|
||||||
|
isSelected={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClick={() => setJournalTarget(item)}
|
||||||
|
details={[
|
||||||
|
{ label: '작성일자', value: item.writeDate },
|
||||||
|
{ label: '공급가액', value: `${item.supplyAmount.toLocaleString()}원` },
|
||||||
|
{ label: '세액', value: `${item.taxAmount.toLocaleString()}원` },
|
||||||
|
{ label: '합계', value: `${item.totalAmount.toLocaleString()}원` },
|
||||||
|
{ label: '과세여부', value: TAX_TYPE_LABELS[item.taxType] },
|
||||||
|
{ label: '소스', value: INVOICE_SOURCE_LABELS[item.source] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 범례 (테이블 안) =====
|
||||||
|
tableFooter: (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={14} className="py-2">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 bg-yellow-100 border border-yellow-300 rounded" />
|
||||||
|
<span>수기 세금계산서</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 bg-white border border-gray-300 rounded" />
|
||||||
|
<span>홈택스 연동 세금계산서</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 기간 요약 (테이블 뒤) =====
|
||||||
|
afterTableContent: () => (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-sm font-semibold mb-3">기간 요약</div>
|
||||||
|
<div className="flex items-center justify-center gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">매출 합계 (공급가액 + 세액)</div>
|
||||||
|
<div className="text-xl font-bold">{summary.salesTotalAmount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-muted-foreground">⊖</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">매입 합계 (공급가액 + 세액)</div>
|
||||||
|
<div className="text-xl font-bold">{summary.purchaseTotalAmount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-muted-foreground">=</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">예상 부가세</div>
|
||||||
|
<div className={`text-xl font-bold ${periodDifference >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
|
||||||
|
{periodDifference.toLocaleString()}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
invoiceData,
|
||||||
|
pagination,
|
||||||
|
summary,
|
||||||
|
dateType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedQuarter,
|
||||||
|
vendorSearch,
|
||||||
|
periodDifference,
|
||||||
|
handleQuarterClick,
|
||||||
|
handleSearch,
|
||||||
|
handleExcelDownload,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UniversalListPage
|
||||||
|
config={config}
|
||||||
|
initialData={invoiceData}
|
||||||
|
externalPagination={{
|
||||||
|
currentPage: pagination.currentPage,
|
||||||
|
totalPages: pagination.lastPage,
|
||||||
|
totalItems: pagination.total,
|
||||||
|
itemsPerPage: pagination.perPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
externalIsLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 수기 입력 팝업 */}
|
||||||
|
<ManualEntryModal
|
||||||
|
open={showManualEntry}
|
||||||
|
onOpenChange={setShowManualEntry}
|
||||||
|
onSuccess={handleManualEntrySuccess}
|
||||||
|
defaultDivision={activeTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분개 수정 팝업 */}
|
||||||
|
{journalTarget && (
|
||||||
|
<JournalEntryModal
|
||||||
|
open={!!journalTarget}
|
||||||
|
onOpenChange={(open: boolean) => !open && setJournalTarget(null)}
|
||||||
|
invoice={journalTarget}
|
||||||
|
onSuccess={handleJournalSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/components/accounting/TaxInvoiceManagement/types.ts
Normal file
266
src/components/accounting/TaxInvoiceManagement/types.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 세금계산서 관리 - 타입 및 상수 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 탭 구분 =====
|
||||||
|
export type InvoiceTab = 'sales' | 'purchase';
|
||||||
|
|
||||||
|
export const TAB_OPTIONS: { value: InvoiceTab; label: string }[] = [
|
||||||
|
{ value: 'sales', label: '매출' },
|
||||||
|
{ value: 'purchase', label: '매입' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 일자유형 =====
|
||||||
|
export const DATE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'write_date', label: '작성일자' },
|
||||||
|
{ value: 'send_date', label: '전송일자' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 과세유형 =====
|
||||||
|
export type TaxType = 'taxable' | 'zero_rate' | 'tax_free';
|
||||||
|
|
||||||
|
export const TAX_TYPE_OPTIONS: { value: TaxType; label: string }[] = [
|
||||||
|
{ value: 'taxable', label: '과세' },
|
||||||
|
{ value: 'zero_rate', label: '영세' },
|
||||||
|
{ value: 'tax_free', label: '면세' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAX_TYPE_LABELS: Record<TaxType, string> = {
|
||||||
|
taxable: '과세',
|
||||||
|
zero_rate: '영세',
|
||||||
|
tax_free: '면세',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 영수구분 =====
|
||||||
|
export type ReceiptType = 'receipt' | 'claim';
|
||||||
|
|
||||||
|
export const RECEIPT_TYPE_OPTIONS: { value: ReceiptType; label: string }[] = [
|
||||||
|
{ value: 'receipt', label: '영수' },
|
||||||
|
{ value: 'claim', label: '청구' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
|
||||||
|
receipt: '영수',
|
||||||
|
claim: '청구',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 세금계산서 상태 =====
|
||||||
|
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
|
||||||
|
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
|
||||||
|
error: { label: '오류', color: 'bg-red-100 text-red-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 소스 구분 (수기/홈택스) =====
|
||||||
|
export type InvoiceSource = 'manual' | 'hometax';
|
||||||
|
|
||||||
|
export const INVOICE_SOURCE_LABELS: Record<InvoiceSource, string> = {
|
||||||
|
manual: '수기',
|
||||||
|
hometax: '홈택스',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 구분 (매출/매입) =====
|
||||||
|
export const DIVISION_OPTIONS = [
|
||||||
|
{ value: 'sales', label: '매출' },
|
||||||
|
{ value: 'purchase', label: '매입' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 세금계산서 레코드 (프론트엔드) =====
|
||||||
|
export interface TaxInvoiceMgmtRecord {
|
||||||
|
id: string;
|
||||||
|
division: InvoiceTab;
|
||||||
|
writeDate: string;
|
||||||
|
issueDate: string | null;
|
||||||
|
vendorName: string;
|
||||||
|
vendorBusinessNumber: string;
|
||||||
|
taxType: TaxType;
|
||||||
|
itemName: string;
|
||||||
|
supplyAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
receiptType: ReceiptType;
|
||||||
|
documentNumber: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
source: InvoiceSource;
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== API 응답 타입 (snake_case) =====
|
||||||
|
export interface TaxInvoiceMgmtApiData {
|
||||||
|
id: number;
|
||||||
|
division: string;
|
||||||
|
write_date: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
vendor_name: string;
|
||||||
|
vendor_business_number: string;
|
||||||
|
tax_type: string;
|
||||||
|
item_name: string;
|
||||||
|
supply_amount: string | number;
|
||||||
|
tax_amount: string | number;
|
||||||
|
total_amount: string | number;
|
||||||
|
receipt_type: string;
|
||||||
|
document_number: string;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
memo: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 요약 데이터 =====
|
||||||
|
export interface TaxInvoiceSummary {
|
||||||
|
salesSupplyAmount: number;
|
||||||
|
salesTaxAmount: number;
|
||||||
|
salesTotalAmount: number;
|
||||||
|
salesCount: number;
|
||||||
|
purchaseSupplyAmount: number;
|
||||||
|
purchaseTaxAmount: number;
|
||||||
|
purchaseTotalAmount: number;
|
||||||
|
purchaseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxInvoiceSummaryApiData {
|
||||||
|
sales_supply_amount: number;
|
||||||
|
sales_tax_amount: number;
|
||||||
|
sales_total_amount: number;
|
||||||
|
sales_count: number;
|
||||||
|
purchase_supply_amount: number;
|
||||||
|
purchase_tax_amount: number;
|
||||||
|
purchase_total_amount: number;
|
||||||
|
purchase_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 분개 항목 =====
|
||||||
|
export type JournalSide = 'debit' | 'credit';
|
||||||
|
|
||||||
|
export const JOURNAL_SIDE_OPTIONS: { value: JournalSide; label: string }[] = [
|
||||||
|
{ value: 'debit', label: '차변' },
|
||||||
|
{ value: 'credit', label: '대변' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface JournalEntryRow {
|
||||||
|
id: string;
|
||||||
|
side: JournalSide;
|
||||||
|
accountSubject: string;
|
||||||
|
debitAmount: number;
|
||||||
|
creditAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntryData {
|
||||||
|
invoiceId: string;
|
||||||
|
rows: JournalEntryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카드 내역 레코드 =====
|
||||||
|
export interface CardHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
transactionDate: string;
|
||||||
|
merchantName: string;
|
||||||
|
amount: number;
|
||||||
|
approvalNumber: string;
|
||||||
|
businessNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHistoryApiData {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
merchant_name: string;
|
||||||
|
amount: string | number;
|
||||||
|
approval_number: string;
|
||||||
|
business_number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수기 입력 폼 데이터 =====
|
||||||
|
export interface ManualEntryFormData {
|
||||||
|
division: InvoiceTab;
|
||||||
|
writeDate: string;
|
||||||
|
vendorName: string;
|
||||||
|
vendorBusinessNumber: string;
|
||||||
|
supplyAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
itemName: string;
|
||||||
|
taxType: TaxType;
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 옵션 =====
|
||||||
|
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||||
|
{ value: '', label: '선택' },
|
||||||
|
{ value: 'sales', label: '매출' },
|
||||||
|
{ value: 'purchasePayment', label: '매입대금' },
|
||||||
|
{ value: 'salesVat', label: '부가세예수금' },
|
||||||
|
{ value: 'purchaseVat', label: '부가세대급금' },
|
||||||
|
{ value: 'accountsReceivable', label: '외상매출금' },
|
||||||
|
{ value: 'accountsPayable', label: '외상매입금' },
|
||||||
|
{ value: 'cashAndDeposits', label: '현금및예금' },
|
||||||
|
{ value: 'advance', label: '선급금' },
|
||||||
|
{ value: 'advanceReceived', label: '선수금' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== API → Frontend 변환 =====
|
||||||
|
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
division: apiData.division as InvoiceTab,
|
||||||
|
writeDate: apiData.write_date,
|
||||||
|
issueDate: apiData.issue_date,
|
||||||
|
vendorName: apiData.vendor_name,
|
||||||
|
vendorBusinessNumber: apiData.vendor_business_number,
|
||||||
|
taxType: apiData.tax_type as TaxType,
|
||||||
|
itemName: apiData.item_name,
|
||||||
|
supplyAmount: Number(apiData.supply_amount),
|
||||||
|
taxAmount: Number(apiData.tax_amount),
|
||||||
|
totalAmount: Number(apiData.total_amount),
|
||||||
|
receiptType: apiData.receipt_type as ReceiptType,
|
||||||
|
documentNumber: apiData.document_number,
|
||||||
|
status: apiData.status as InvoiceStatus,
|
||||||
|
source: apiData.source as InvoiceSource,
|
||||||
|
memo: apiData.memo || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Frontend → API 변환 =====
|
||||||
|
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
division: data.division,
|
||||||
|
write_date: data.writeDate,
|
||||||
|
vendor_name: data.vendorName,
|
||||||
|
vendor_business_number: data.vendorBusinessNumber,
|
||||||
|
supply_amount: data.supplyAmount,
|
||||||
|
tax_amount: data.taxAmount,
|
||||||
|
total_amount: data.totalAmount,
|
||||||
|
item_name: data.itemName,
|
||||||
|
tax_type: data.taxType,
|
||||||
|
memo: data.memo || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카드 내역 API → Frontend 변환 =====
|
||||||
|
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
transactionDate: apiData.transaction_date,
|
||||||
|
merchantName: apiData.merchant_name,
|
||||||
|
amount: Number(apiData.amount),
|
||||||
|
approvalNumber: apiData.approval_number,
|
||||||
|
businessNumber: apiData.business_number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 요약 API → Frontend 변환 =====
|
||||||
|
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
|
||||||
|
return {
|
||||||
|
salesSupplyAmount: apiData.sales_supply_amount,
|
||||||
|
salesTaxAmount: apiData.sales_tax_amount,
|
||||||
|
salesTotalAmount: apiData.sales_total_amount,
|
||||||
|
salesCount: apiData.sales_count,
|
||||||
|
purchaseSupplyAmount: apiData.purchase_supply_amount,
|
||||||
|
purchaseTaxAmount: apiData.purchase_tax_amount,
|
||||||
|
purchaseTotalAmount: apiData.purchase_total_amount,
|
||||||
|
purchaseCount: apiData.purchase_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -606,7 +606,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
(으)로 모두 변경하시겠습니까?
|
(으)로 모두 변경하시겠습니까?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-3">
|
||||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
602
src/components/hr/CardManagement/CardDetail.tsx
Normal file
602
src/components/hr/CardManagement/CardDetail.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { CreditCard, Save, Trash2, X, Edit, Loader2, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { CardNumberInput } from '@/components/ui/card-number-input';
|
||||||
|
import { formatCardNumber } from '@/lib/formatters';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Card as CardType, CardFormData, CardStatus } from './types';
|
||||||
|
import {
|
||||||
|
CARD_COMPANIES,
|
||||||
|
CARD_TYPE_OPTIONS,
|
||||||
|
PAYMENT_DAY_OPTIONS,
|
||||||
|
CARD_STATUS_LABELS,
|
||||||
|
CARD_STATUS_COLORS,
|
||||||
|
getCardCompanyLabel,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
createCard,
|
||||||
|
updateCard,
|
||||||
|
deleteCard,
|
||||||
|
getActiveEmployees,
|
||||||
|
getApprovalFormUrl,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
function formatCurrency(value: number): string {
|
||||||
|
return value.toLocaleString('ko-KR') + '원';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiryDate(value: string): string {
|
||||||
|
if (value && value.length === 4) {
|
||||||
|
return `${value.slice(0, 2)}/${value.slice(2)}`;
|
||||||
|
}
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaymentDayLabel(value: string): string {
|
||||||
|
const option = PAYMENT_DAY_OPTIONS.find(o => o.value === value);
|
||||||
|
return option?.label || value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardTypeLabel(value: string): string {
|
||||||
|
const option = CARD_TYPE_OPTIONS.find(o => o.value === value);
|
||||||
|
return option?.label || value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDetailProps {
|
||||||
|
card?: CardType;
|
||||||
|
mode: 'create' | 'view' | 'edit';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [mode, setMode] = useState(initialMode);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isLoadingApproval, setIsLoadingApproval] = useState(false);
|
||||||
|
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlMode = searchParams.get('mode');
|
||||||
|
if (urlMode === 'edit' && card) setMode('edit');
|
||||||
|
}, [searchParams, card]);
|
||||||
|
|
||||||
|
// 직원 목록 로드 (수정/등록 모드)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'view') {
|
||||||
|
getActiveEmployees().then(result => {
|
||||||
|
if (result.success && result.data) setEmployees(result.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CardFormData>({
|
||||||
|
cardCompany: card?.cardCompany || '',
|
||||||
|
cardType: card?.cardType || '',
|
||||||
|
cardNumber: card?.cardNumber || '',
|
||||||
|
cardName: card?.cardName || '',
|
||||||
|
alias: card?.alias || '',
|
||||||
|
expiryDate: card?.expiryDate || '',
|
||||||
|
csv: card?.csv || '',
|
||||||
|
paymentDay: card?.paymentDay || '',
|
||||||
|
pinPrefix: '',
|
||||||
|
totalLimit: card?.totalLimit || 0,
|
||||||
|
usedAmount: card?.usedAmount || 0,
|
||||||
|
remainingLimit: card?.remainingLimit || 0,
|
||||||
|
status: card?.status || 'active',
|
||||||
|
userId: card?.user?.id || '',
|
||||||
|
departmentId: card?.user?.departmentId || '',
|
||||||
|
positionId: card?.user?.positionId || '',
|
||||||
|
memo: card?.memo || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isViewMode = mode === 'view';
|
||||||
|
const isCreateMode = mode === 'create';
|
||||||
|
|
||||||
|
const handleChange = useCallback((field: keyof CardFormData, value: string | number) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.cardCompany) {
|
||||||
|
toast.error('카드사를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (isCreateMode) {
|
||||||
|
const result = await createCard(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('카드가 등록되었습니다.');
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '카드 등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!card?.id) return;
|
||||||
|
const result = await updateCard(card.id, formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('카드가 수정되었습니다.');
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '카드 수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!card?.id) return;
|
||||||
|
const result = await deleteCard(card.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('카드가 삭제되었습니다.');
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '카드 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isCreateMode) {
|
||||||
|
router.push('/ko/hr/card-management');
|
||||||
|
} else {
|
||||||
|
setMode('view');
|
||||||
|
if (card) {
|
||||||
|
setFormData({
|
||||||
|
cardCompany: card.cardCompany || '',
|
||||||
|
cardType: card.cardType || '',
|
||||||
|
cardNumber: card.cardNumber || '',
|
||||||
|
cardName: card.cardName || '',
|
||||||
|
alias: card.alias || '',
|
||||||
|
expiryDate: card.expiryDate || '',
|
||||||
|
csv: card.csv || '',
|
||||||
|
paymentDay: card.paymentDay || '',
|
||||||
|
pinPrefix: '',
|
||||||
|
totalLimit: card.totalLimit || 0,
|
||||||
|
usedAmount: card.usedAmount || 0,
|
||||||
|
remainingLimit: card.remainingLimit || 0,
|
||||||
|
status: card.status || 'active',
|
||||||
|
userId: card.user?.id || '',
|
||||||
|
departmentId: card.user?.departmentId || '',
|
||||||
|
positionId: card.user?.positionId || '',
|
||||||
|
memo: card.memo || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setMode('edit');
|
||||||
|
if (card?.id) {
|
||||||
|
router.push(`/ko/hr/card-management/${card.id}?mode=edit`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprovalForm = async () => {
|
||||||
|
if (!card?.id) return;
|
||||||
|
setIsLoadingApproval(true);
|
||||||
|
try {
|
||||||
|
const result = await getApprovalFormUrl(card.id);
|
||||||
|
if (result.success && result.data?.url) {
|
||||||
|
window.open(result.data.url, '_blank');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '품의서 작성 페이지 URL 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('품의서 작성 URL 조회 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingApproval(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader title="카드 상세" description="카드 정보를 관리합니다" icon={CreditCard} />
|
||||||
|
<ContentSkeleton type="detail" />
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 뷰 모드 =====
|
||||||
|
if (isViewMode) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader title="카드 상세" description="카드 정보를 관리합니다" icon={CreditCard} />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">카드사</dt>
|
||||||
|
<dd className="text-sm mt-1">{getCardCompanyLabel(card?.cardCompany || '')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">종류</dt>
|
||||||
|
<dd className="text-sm mt-1">{getCardTypeLabel(card?.cardType || '')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">카드번호</dt>
|
||||||
|
<dd className="text-sm mt-1 font-mono">{card?.cardNumber ? formatCardNumber(card.cardNumber) : '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">카드명</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.cardName || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">카드 별칭</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.alias || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">유효기간(15/05)</dt>
|
||||||
|
<dd className="text-sm mt-1">{formatExpiryDate(card?.expiryDate || '')}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">CSV</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.csv || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">결제일</dt>
|
||||||
|
<dd className="text-sm mt-1">{getPaymentDayLabel(card?.paymentDay || '')}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">총 한도</dt>
|
||||||
|
<dd className="text-sm mt-1 font-medium">{formatCurrency(card?.totalLimit || 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">사용 금액</dt>
|
||||||
|
<dd className="text-sm mt-1 font-medium text-red-600">{formatCurrency(card?.usedAmount || 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">잔여한도</dt>
|
||||||
|
<dd className="text-sm mt-1 font-medium">{formatCurrency(card?.remainingLimit || 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">상태</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<Badge className={CARD_STATUS_COLORS[card?.status || 'active']}>
|
||||||
|
{CARD_STATUS_LABELS[card?.status || 'active']}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용자 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">사용자 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">부서</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.user?.departmentName || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">사용자</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.user?.employeeName || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">직책</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.user?.positionName || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">메모</dt>
|
||||||
|
<dd className="text-sm mt-1">{card?.memo || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 선결제 신청 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">선결제 신청</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
한도를 초과한 월 경우 품의서를 작성해서 승인을 요청하세요
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleApprovalForm}
|
||||||
|
disabled={isLoadingApproval}
|
||||||
|
>
|
||||||
|
{isLoadingApproval ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
품의서 작성
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowDeleteDialog(true)} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEdit}>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOpenChange={setShowDeleteDialog}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
카드를 정말 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
삭제된 카드 정보는 복구할 수 없습니다.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 생성/수정 모드 =====
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={isCreateMode ? '수기 카드 등록' : '카드 수정'}
|
||||||
|
description="카드 정보를 관리합니다"
|
||||||
|
icon={CreditCard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Row 1: 카드사 | 종류 | 카드번호 | 카드명 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드사 <span className="text-red-500">*</span></Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cardCompany}
|
||||||
|
onValueChange={(v) => handleChange('cardCompany', v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카드사 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CARD_COMPANIES.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>종류</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cardType || '_none'}
|
||||||
|
onValueChange={(v) => handleChange('cardType', v === '_none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="종류 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">선택 안함</SelectItem>
|
||||||
|
{CARD_TYPE_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드번호</Label>
|
||||||
|
<CardNumberInput
|
||||||
|
value={formData.cardNumber}
|
||||||
|
onChange={(v) => handleChange('cardNumber', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.cardName}
|
||||||
|
onChange={(e) => handleChange('cardName', e.target.value)}
|
||||||
|
placeholder="카드명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 카드 별칭 | 유효기간 | CSV | 결제일 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드 별칭</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.alias}
|
||||||
|
onChange={(e) => handleChange('alias', e.target.value)}
|
||||||
|
placeholder="별칭"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>유효기간(15/05)</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.expiryDate}
|
||||||
|
onChange={(e) => handleChange('expiryDate', e.target.value)}
|
||||||
|
placeholder="MMYY"
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CSV</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.csv}
|
||||||
|
onChange={(e) => handleChange('csv', e.target.value)}
|
||||||
|
placeholder="CSV"
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>결제일</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.paymentDay || '_none'}
|
||||||
|
onValueChange={(v) => handleChange('paymentDay', v === '_none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="결제일 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">선택 안함</SelectItem>
|
||||||
|
{PAYMENT_DAY_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 총 한도 | 사용 금액 | 잔여한도 | 상태 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>총 한도</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.totalLimit || ''}
|
||||||
|
onChange={(e) => handleChange('totalLimit', Number(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>사용 금액</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.usedAmount || ''}
|
||||||
|
onChange={(e) => handleChange('usedAmount', Number(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>잔여한도</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.remainingLimit || ''}
|
||||||
|
onChange={(e) => handleChange('remainingLimit', Number(e.target.value) || 0)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>상태</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(v) => handleChange('status', v as CardStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="상태 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
|
||||||
|
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용자 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">사용자 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>부서 / 사용자 / 직책</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.userId || '_none'}
|
||||||
|
onValueChange={(v) => handleChange('userId', v === '_none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="사용자 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_none">미지정</SelectItem>
|
||||||
|
{employees.map(emp => (
|
||||||
|
<SelectItem key={emp.id} value={emp.id}>{emp.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>메모</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(e) => handleChange('memo', e.target.value)}
|
||||||
|
placeholder="메모"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isCreateMode ? '등록' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { CreditCard, Edit, Trash2, Plus } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
|
||||||
import {
|
|
||||||
UniversalListPage,
|
|
||||||
type UniversalListConfig,
|
|
||||||
type SelectionHandlers,
|
|
||||||
type RowClickHandlers,
|
|
||||||
} from '@/components/templates/UniversalListPage';
|
|
||||||
import type { Card } from './types';
|
|
||||||
import {
|
|
||||||
CARD_STATUS_LABELS,
|
|
||||||
CARD_STATUS_COLORS,
|
|
||||||
getCardCompanyLabel,
|
|
||||||
} from './types';
|
|
||||||
import { getCards, deleteCard, deleteCards } from './actions';
|
|
||||||
|
|
||||||
// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234)
|
|
||||||
const maskCardNumber = (cardNumber: string): string => {
|
|
||||||
return cardNumber;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CardManagementUnifiedProps {
|
|
||||||
initialData?: Card[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardManagementUnified({ initialData }: CardManagementUnifiedProps) {
|
|
||||||
// UniversalListPage Config 정의
|
|
||||||
const config: UniversalListConfig<Card> = useMemo(() => ({
|
|
||||||
// ===== 페이지 기본 정보 =====
|
|
||||||
title: '카드관리',
|
|
||||||
description: '카드 목록을 관리합니다',
|
|
||||||
icon: CreditCard,
|
|
||||||
basePath: '/hr/card-management',
|
|
||||||
|
|
||||||
// ===== ID 추출 =====
|
|
||||||
idField: 'id',
|
|
||||||
|
|
||||||
// ===== API 액션 =====
|
|
||||||
actions: {
|
|
||||||
getList: async () => {
|
|
||||||
const result = await getCards({ per_page: 100 });
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
data: result.data,
|
|
||||||
totalCount: result.data?.length || 0,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
deleteItem: async (id: string) => {
|
|
||||||
return await deleteCard(id);
|
|
||||||
},
|
|
||||||
deleteBulk: async (ids: string[]) => {
|
|
||||||
return await deleteCards(ids);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 테이블 컬럼 =====
|
|
||||||
columns: [
|
|
||||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
|
||||||
{ key: 'cardCompany', label: '카드사', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
|
|
||||||
{ key: 'cardName', label: '카드명', className: 'min-w-[120px]' },
|
|
||||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
|
||||||
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'userName', label: '사용자', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// ===== 클라이언트 사이드 필터링 =====
|
|
||||||
clientSideFiltering: true,
|
|
||||||
|
|
||||||
// 탭 필터 함수
|
|
||||||
tabFilter: (item: Card, activeTab: string) => {
|
|
||||||
if (activeTab === 'all') return true;
|
|
||||||
return item.status === activeTab;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 검색 필터 함수
|
|
||||||
searchFilter: (item: Card, searchValue: string) => {
|
|
||||||
const search = searchValue.toLowerCase();
|
|
||||||
return (
|
|
||||||
item.cardName.toLowerCase().includes(search) ||
|
|
||||||
item.cardNumber.includes(search) ||
|
|
||||||
getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) ||
|
|
||||||
(item.user?.employeeName.toLowerCase().includes(search) || false)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 탭 설정 (데이터 기반으로 count 업데이트) =====
|
|
||||||
tabs: [
|
|
||||||
{ value: 'all', label: '전체', count: 0, color: 'gray' },
|
|
||||||
{ value: 'active', label: '사용', count: 0, color: 'green' },
|
|
||||||
{ value: 'suspended', label: '정지', count: 0, color: 'red' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// ===== 검색 설정 =====
|
|
||||||
searchPlaceholder: '카드명, 카드번호, 카드사, 사용자 검색...',
|
|
||||||
|
|
||||||
// ===== 상세 보기 모드 =====
|
|
||||||
detailMode: 'page',
|
|
||||||
|
|
||||||
// ===== 헤더 액션 =====
|
|
||||||
headerActions: ({ onCreate }) => (
|
|
||||||
<Button className="ml-auto" onClick={onCreate}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
카드 등록
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
|
|
||||||
// ===== 삭제 확인 메시지 =====
|
|
||||||
deleteConfirmMessage: {
|
|
||||||
title: '카드 삭제',
|
|
||||||
description: '삭제된 카드 정보는 복구할 수 없습니다.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 테이블 행 렌더링 =====
|
|
||||||
renderTableRow: (
|
|
||||||
item: Card,
|
|
||||||
index: number,
|
|
||||||
globalIndex: number,
|
|
||||||
handlers: SelectionHandlers & RowClickHandlers<Card>
|
|
||||||
) => {
|
|
||||||
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className="hover:bg-muted/50 cursor-pointer"
|
|
||||||
onClick={() => onRowClick?.()}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={onToggle}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-center">
|
|
||||||
{globalIndex}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{getCardCompanyLabel(item.cardCompany)}</TableCell>
|
|
||||||
<TableCell className="font-mono">{maskCardNumber(item.cardNumber)}</TableCell>
|
|
||||||
<TableCell>{item.cardName}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
|
||||||
{CARD_STATUS_LABELS[item.status]}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{item.user?.departmentName || '-'}</TableCell>
|
|
||||||
<TableCell>{item.user?.employeeName || '-'}</TableCell>
|
|
||||||
<TableCell>{item.user?.positionName || '-'}</TableCell>
|
|
||||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onEdit?.(item)}
|
|
||||||
title="수정"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onDelete?.(item)}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 모바일 카드 렌더링 =====
|
|
||||||
renderMobileCard: (
|
|
||||||
item: Card,
|
|
||||||
index: number,
|
|
||||||
globalIndex: number,
|
|
||||||
handlers: SelectionHandlers & RowClickHandlers<Card>
|
|
||||||
) => {
|
|
||||||
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListMobileCard
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
title={item.cardName}
|
|
||||||
headerBadges={
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
#{globalIndex}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{getCardCompanyLabel(item.cardCompany)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
statusBadge={
|
|
||||||
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
|
||||||
{CARD_STATUS_LABELS[item.status]}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
isSelected={isSelected}
|
|
||||||
onToggleSelection={onToggle}
|
|
||||||
onCardClick={() => onRowClick?.()}
|
|
||||||
infoGrid={
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
||||||
<InfoField label="카드번호" value={maskCardNumber(item.cardNumber)} />
|
|
||||||
<InfoField label="유효기간" value={`${item.expiryDate.slice(0, 2)}/${item.expiryDate.slice(2)}`} />
|
|
||||||
<InfoField label="부서" value={item.user?.departmentName || '-'} />
|
|
||||||
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
|
||||||
<InfoField label="직책" value={item.user?.positionName || '-'} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
isSelected ? (
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
className="flex-1 min-w-[100px] h-11"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 추가 옵션 =====
|
|
||||||
showCheckbox: true,
|
|
||||||
showRowNumber: true,
|
|
||||||
itemsPerPage: 20,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UniversalListPage<Card>
|
|
||||||
config={config}
|
|
||||||
initialData={initialData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { CreditCard, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
|
||||||
import type { Card as CardType } from '../types';
|
|
||||||
import {
|
|
||||||
CARD_STATUS_LABELS,
|
|
||||||
CARD_STATUS_COLORS,
|
|
||||||
getCardCompanyLabel,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
interface CardDetailProps {
|
|
||||||
card: CardType;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardDetail({ card, onEdit, onDelete }: CardDetailProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
router.push('/ko/hr/card-management');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 유효기간 포맷 (MMYY -> MM/YY)
|
|
||||||
const formatExpiryDate = (date: string) => {
|
|
||||||
if (date.length === 4) {
|
|
||||||
return `${date.slice(0, 2)}/${date.slice(2)}`;
|
|
||||||
}
|
|
||||||
return date;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader
|
|
||||||
title="카드 상세"
|
|
||||||
description="카드 정보를 관리합니다"
|
|
||||||
icon={CreditCard}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
|
||||||
<Badge className={CARD_STATUS_COLORS[card.status]}>
|
|
||||||
{CARD_STATUS_LABELS[card.status]}
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">카드사</dt>
|
|
||||||
<dd className="text-sm mt-1">{getCardCompanyLabel(card.cardCompany)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">카드번호</dt>
|
|
||||||
<dd className="text-sm mt-1 font-mono">{card.cardNumber}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">유효기간</dt>
|
|
||||||
<dd className="text-sm mt-1">{formatExpiryDate(card.expiryDate)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">카드 비밀번호 앞 2자리</dt>
|
|
||||||
<dd className="text-sm mt-1">**</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">카드명</dt>
|
|
||||||
<dd className="text-sm mt-1">{card.cardName}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">상태</dt>
|
|
||||||
<dd className="text-sm mt-1">
|
|
||||||
<Badge className={CARD_STATUS_COLORS[card.status]}>
|
|
||||||
{CARD_STATUS_LABELS[card.status]}
|
|
||||||
</Badge>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 사용자 정보 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">사용자 정보</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground">부서 / 이름 / 직책</dt>
|
|
||||||
<dd className="text-sm mt-1">
|
|
||||||
{card.user ? (
|
|
||||||
<span>
|
|
||||||
{card.user.departmentName} / {card.user.employeeName} / {card.user.positionName}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">미지정</span>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 버튼 영역 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button variant="outline" onClick={handleBack}>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
목록으로
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onEdit}>
|
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
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 { CreditCard, ArrowLeft, Save } from 'lucide-react';
|
|
||||||
import type { Card as CardType, CardFormData, CardCompany, CardStatus } from '../types';
|
|
||||||
import { CARD_COMPANIES, CARD_STATUS_LABELS } from '../types';
|
|
||||||
import { getActiveEmployees } from '../actions';
|
|
||||||
|
|
||||||
interface CardFormProps {
|
|
||||||
mode: 'create' | 'edit';
|
|
||||||
card?: CardType;
|
|
||||||
onSubmit: (data: CardFormData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardForm({ mode, card, onSubmit }: CardFormProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [formData, setFormData] = useState<CardFormData>({
|
|
||||||
cardCompany: '',
|
|
||||||
cardNumber: '',
|
|
||||||
cardName: '',
|
|
||||||
expiryDate: '',
|
|
||||||
pinPrefix: '',
|
|
||||||
status: 'active',
|
|
||||||
userId: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 직원 목록 상태
|
|
||||||
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
|
|
||||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
|
||||||
|
|
||||||
// 직원 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadEmployees = async () => {
|
|
||||||
setIsLoadingEmployees(true);
|
|
||||||
const result = await getActiveEmployees();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setEmployees(result.data);
|
|
||||||
}
|
|
||||||
setIsLoadingEmployees(false);
|
|
||||||
};
|
|
||||||
loadEmployees();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 수정 모드일 때 기존 데이터 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === 'edit' && card) {
|
|
||||||
setFormData({
|
|
||||||
cardCompany: card.cardCompany,
|
|
||||||
cardNumber: card.cardNumber,
|
|
||||||
cardName: card.cardName,
|
|
||||||
expiryDate: card.expiryDate,
|
|
||||||
pinPrefix: card.pinPrefix,
|
|
||||||
status: card.status,
|
|
||||||
userId: card.user?.id || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [mode, card]);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (mode === 'edit' && card) {
|
|
||||||
router.push(`/ko/hr/card-management/${card.id}?mode=view`);
|
|
||||||
} else {
|
|
||||||
router.push('/ko/hr/card-management');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 카드번호 포맷팅 (1234-1234-1234-1234)
|
|
||||||
const handleCardNumberChange = (value: string) => {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 16);
|
|
||||||
const parts = digits.match(/.{1,4}/g) || [];
|
|
||||||
const formatted = parts.join('-');
|
|
||||||
setFormData((prev: CardFormData) => ({ ...prev, cardNumber: formatted }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 유효기간 포맷팅 (MMYY)
|
|
||||||
const handleExpiryDateChange = (value: string) => {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 4);
|
|
||||||
setFormData((prev: CardFormData) => ({ ...prev, expiryDate: digits }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비밀번호 앞 2자리
|
|
||||||
const handlePinPrefixChange = (value: string) => {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 2);
|
|
||||||
setFormData((prev: CardFormData) => ({ ...prev, pinPrefix: digits }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader
|
|
||||||
title={mode === 'create' ? '카드 등록' : '카드 수정'}
|
|
||||||
description={mode === 'create' ? '새로운 카드를 등록합니다' : '카드 정보를 수정합니다'}
|
|
||||||
icon={CreditCard}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cardCompany">카드사</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.cardCompany}
|
|
||||||
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, cardCompany: value as CardCompany }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="cardCompany">
|
|
||||||
<SelectValue placeholder="카드사를 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CARD_COMPANIES.map((company) => (
|
|
||||||
<SelectItem key={company.value} value={company.value}>
|
|
||||||
{company.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cardNumber">카드번호</Label>
|
|
||||||
<Input
|
|
||||||
id="cardNumber"
|
|
||||||
value={formData.cardNumber}
|
|
||||||
onChange={(e) => handleCardNumberChange(e.target.value)}
|
|
||||||
placeholder="1234-1234-1234-1234"
|
|
||||||
maxLength={19}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="expiryDate">유효기간</Label>
|
|
||||||
<Input
|
|
||||||
id="expiryDate"
|
|
||||||
value={formData.expiryDate}
|
|
||||||
onChange={(e) => handleExpiryDateChange(e.target.value)}
|
|
||||||
placeholder="MMYY"
|
|
||||||
maxLength={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pinPrefix">카드 비밀번호 앞 2자리</Label>
|
|
||||||
<Input
|
|
||||||
id="pinPrefix"
|
|
||||||
type="password"
|
|
||||||
value={formData.pinPrefix}
|
|
||||||
onChange={(e) => handlePinPrefixChange(e.target.value)}
|
|
||||||
placeholder="**"
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cardName">카드명</Label>
|
|
||||||
<Input
|
|
||||||
id="cardName"
|
|
||||||
value={formData.cardName}
|
|
||||||
onChange={(e) => setFormData((prev: CardFormData) => ({ ...prev, cardName: e.target.value }))}
|
|
||||||
placeholder="카드명을 입력해주세요"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">상태</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, status: value as CardStatus }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="status">
|
|
||||||
<SelectValue placeholder="상태 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
|
|
||||||
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 사용자 정보 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">사용자 정보</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="userId">부서 / 이름 / 직책</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.userId}
|
|
||||||
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, userId: value }))}
|
|
||||||
disabled={isLoadingEmployees}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="userId">
|
|
||||||
<SelectValue placeholder={isLoadingEmployees ? '직원 목록 로딩 중...' : '선택해서 해당 카드의 사용자로 설정'} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{employees.map((employee) => (
|
|
||||||
<SelectItem key={employee.id} value={employee.id}>
|
|
||||||
{employee.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 버튼 영역 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button type="button" variant="outline" onClick={handleBack}>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{mode === 'create' ? '등록' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
import { buildApiUrl } from '@/lib/api/query-params';
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import type { Card, CardFormData, CardStatus } from './types';
|
import type { Card, CardFormData, CardStatus, CardStats, CardListFilter } from './types';
|
||||||
|
|
||||||
// API 응답 타입
|
// API 응답 타입
|
||||||
interface TenantProfile {
|
interface TenantProfile {
|
||||||
@@ -59,14 +59,24 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
|||||||
const profile = apiData.assigned_user?.tenant_profiles?.[0];
|
const profile = apiData.assigned_user?.tenant_profiles?.[0];
|
||||||
const department = profile?.department;
|
const department = profile?.department;
|
||||||
|
|
||||||
|
const raw = apiData as CardApiData & Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(apiData.id),
|
id: String(apiData.id),
|
||||||
cardCompany: apiData.card_company as Card['cardCompany'],
|
cardCompany: apiData.card_company as Card['cardCompany'],
|
||||||
|
cardType: (raw.card_type as string) || '',
|
||||||
cardNumber: `****-****-****-${apiData.card_number_last4}`,
|
cardNumber: `****-****-****-${apiData.card_number_last4}`,
|
||||||
cardName: apiData.card_name,
|
cardName: apiData.card_name,
|
||||||
|
alias: (raw.alias as string) || '',
|
||||||
expiryDate: apiData.expiry_date ? apiData.expiry_date.replace('/', '') : '',
|
expiryDate: apiData.expiry_date ? apiData.expiry_date.replace('/', '') : '',
|
||||||
|
csv: (raw.csv as string) || '',
|
||||||
|
paymentDay: (raw.payment_day as string) || '',
|
||||||
pinPrefix: '**',
|
pinPrefix: '**',
|
||||||
|
totalLimit: Number(raw.total_limit) || 0,
|
||||||
|
usedAmount: Number(raw.used_amount) || 0,
|
||||||
|
remainingLimit: Number(raw.remaining_limit) || 0,
|
||||||
status: mapApiStatusToFrontend(apiData.status),
|
status: mapApiStatusToFrontend(apiData.status),
|
||||||
|
isManual: (raw.is_manual as boolean) ?? true,
|
||||||
user: apiData.assigned_user ? {
|
user: apiData.assigned_user ? {
|
||||||
id: String(apiData.assigned_user.id),
|
id: String(apiData.assigned_user.id),
|
||||||
departmentId: department ? String(department.id) : '',
|
departmentId: department ? String(department.id) : '',
|
||||||
@@ -76,6 +86,7 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
|||||||
positionId: profile?.position_key || '',
|
positionId: profile?.position_key || '',
|
||||||
positionName: profile?.position_key || '',
|
positionName: profile?.position_key || '',
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
memo: (raw.memo as string) || '',
|
||||||
createdAt: apiData.created_at,
|
createdAt: apiData.created_at,
|
||||||
updatedAt: apiData.updated_at,
|
updatedAt: apiData.updated_at,
|
||||||
};
|
};
|
||||||
@@ -85,11 +96,19 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
|||||||
function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
|
function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
|
||||||
const apiData: Record<string, unknown> = {
|
const apiData: Record<string, unknown> = {
|
||||||
card_company: data.cardCompany,
|
card_company: data.cardCompany,
|
||||||
|
card_type: data.cardType || undefined,
|
||||||
|
card_name: data.cardName,
|
||||||
|
alias: data.alias || undefined,
|
||||||
expiry_date: data.expiryDate.length === 4
|
expiry_date: data.expiryDate.length === 4
|
||||||
? `${data.expiryDate.slice(0, 2)}/${data.expiryDate.slice(2)}`
|
? `${data.expiryDate.slice(0, 2)}/${data.expiryDate.slice(2)}`
|
||||||
: data.expiryDate,
|
: data.expiryDate,
|
||||||
card_name: data.cardName,
|
csv: data.csv || undefined,
|
||||||
|
payment_day: data.paymentDay || undefined,
|
||||||
|
total_limit: data.totalLimit || undefined,
|
||||||
|
used_amount: data.usedAmount || undefined,
|
||||||
|
remaining_limit: data.remainingLimit || undefined,
|
||||||
status: mapFrontendStatusToApi(data.status),
|
status: mapFrontendStatusToApi(data.status),
|
||||||
|
memo: data.memo || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardNumberDigits = data.cardNumber.replace(/-/g, '');
|
const cardNumberDigits = data.cardNumber.replace(/-/g, '');
|
||||||
@@ -109,13 +128,16 @@ function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카드 목록 조회 =====
|
// ===== 카드 목록 조회 =====
|
||||||
export async function getCards(params?: {
|
export async function getCards(params?: Partial<CardListFilter> & { per_page?: number }): Promise<{
|
||||||
search?: string; status?: string; page?: number; per_page?: number;
|
success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string;
|
||||||
}): Promise<{ success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> {
|
}> {
|
||||||
const result = await executeServerAction<CardPaginationData>({
|
const result = await executeServerAction<CardPaginationData>({
|
||||||
url: buildApiUrl('/api/v1/cards', {
|
url: buildApiUrl('/api/v1/cards', {
|
||||||
search: params?.search,
|
search: params?.search,
|
||||||
status: params?.status && params.status !== 'all' ? mapFrontendStatusToApi(params.status as CardStatus) : undefined,
|
status: params?.status && params.status !== 'all' ? mapFrontendStatusToApi(params.status as CardStatus) : undefined,
|
||||||
|
card_company: params?.cardCompany && params.cardCompany !== 'all' ? params.cardCompany : undefined,
|
||||||
|
start_date: params?.startDate,
|
||||||
|
end_date: params?.endDate,
|
||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.per_page,
|
per_page: params?.per_page,
|
||||||
}),
|
}),
|
||||||
@@ -137,6 +159,36 @@ export async function getCards(params?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 카드 통계 조회 =====
|
||||||
|
export async function getCardStats(params?: {
|
||||||
|
startDate?: string; endDate?: string;
|
||||||
|
}): Promise<ActionResult<CardStats>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/cards/stats', {
|
||||||
|
start_date: params?.startDate,
|
||||||
|
end_date: params?.endDate,
|
||||||
|
}),
|
||||||
|
transform: (data: {
|
||||||
|
total_count?: number; upcoming_payment?: number; total_limit?: number; remaining_limit?: number;
|
||||||
|
}) => ({
|
||||||
|
totalCount: data.total_count ?? 0,
|
||||||
|
upcomingPayment: data.upcoming_payment ?? 0,
|
||||||
|
totalLimit: data.total_limit ?? 0,
|
||||||
|
remainingLimit: data.remaining_limit ?? 0,
|
||||||
|
}),
|
||||||
|
errorMessage: '카드 통계 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 품의서 작성 URL 조회 =====
|
||||||
|
export async function getApprovalFormUrl(cardId: string): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/cards/${cardId}/approval-form-url`),
|
||||||
|
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||||
|
errorMessage: '품의서 작성 페이지 URL 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 카드 상세 조회 =====
|
// ===== 카드 상세 조회 =====
|
||||||
export async function getCard(id: string): Promise<ActionResult<Card>> {
|
export async function getCard(id: string): Promise<ActionResult<Card>> {
|
||||||
return executeServerAction({
|
return executeServerAction({
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Card Management - IntegratedDetailTemplate Config
|
|
||||||
*
|
|
||||||
* 카드관리 등록/상세/수정 페이지 설정
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CreditCard } from 'lucide-react';
|
|
||||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate';
|
|
||||||
import type { Card, CardFormData, CardStatus, CardCompany } from './types';
|
|
||||||
import {
|
|
||||||
CARD_COMPANIES,
|
|
||||||
CARD_STATUS_LABELS,
|
|
||||||
getCardCompanyLabel,
|
|
||||||
} from './types';
|
|
||||||
import { getActiveEmployees } from './actions';
|
|
||||||
|
|
||||||
// 상태 옵션
|
|
||||||
const CARD_STATUS_OPTIONS = [
|
|
||||||
{ value: 'active', label: CARD_STATUS_LABELS.active },
|
|
||||||
{ value: 'suspended', label: CARD_STATUS_LABELS.suspended },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const cardConfig: DetailConfig<Card> = {
|
|
||||||
title: '카드',
|
|
||||||
description: '카드 정보를 관리합니다',
|
|
||||||
icon: CreditCard,
|
|
||||||
basePath: '/hr/card-management',
|
|
||||||
|
|
||||||
// 그리드 2열
|
|
||||||
gridColumns: 2,
|
|
||||||
|
|
||||||
// 섹션 정의
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'basic',
|
|
||||||
title: '기본 정보',
|
|
||||||
fields: ['cardCompany', 'cardNumber', 'expiryDate', 'pinPrefix', 'cardName', 'status'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'user',
|
|
||||||
title: '사용자 정보',
|
|
||||||
fields: ['userId'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// 필드 정의
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
key: 'cardCompany',
|
|
||||||
label: '카드사',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: CARD_COMPANIES.map(c => ({ value: c.value, label: c.label })),
|
|
||||||
placeholder: '카드사를 선택하세요',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cardNumber',
|
|
||||||
label: '카드번호',
|
|
||||||
type: 'cardNumber',
|
|
||||||
required: true,
|
|
||||||
placeholder: '0000-0000-0000-0000',
|
|
||||||
helpText: '16자리 카드번호를 입력하세요',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'expiryDate',
|
|
||||||
label: '유효기간',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'MMYY',
|
|
||||||
helpText: '월/년 4자리 (예: 1225)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'pinPrefix',
|
|
||||||
label: '카드 비밀번호 앞 2자리',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: '**',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cardName',
|
|
||||||
label: '카드명',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '카드명을 입력해주세요',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: '상태',
|
|
||||||
type: 'select',
|
|
||||||
required: true,
|
|
||||||
options: CARD_STATUS_OPTIONS,
|
|
||||||
placeholder: '상태 선택',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'userId',
|
|
||||||
label: '부서 / 이름 / 직책',
|
|
||||||
type: 'select',
|
|
||||||
placeholder: '선택해서 해당 카드의 사용자로 설정',
|
|
||||||
gridSpan: 2,
|
|
||||||
// 동적 옵션 로드
|
|
||||||
fetchOptions: async () => {
|
|
||||||
const result = await getActiveEmployees();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
return result.data.map(emp => ({ value: emp.id, label: emp.label }));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// 액션 설정
|
|
||||||
actions: {
|
|
||||||
showDelete: true,
|
|
||||||
showEdit: true,
|
|
||||||
showBack: true,
|
|
||||||
deleteConfirmMessage: {
|
|
||||||
title: '카드 삭제',
|
|
||||||
description: '카드를 정말 삭제하시겠습니까?\n삭제된 카드 정보는 복구할 수 없습니다.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 초기 데이터 변환 (API 응답 → formData)
|
|
||||||
transformInitialData: (card: Card): Record<string, unknown> => ({
|
|
||||||
cardCompany: card.cardCompany || '',
|
|
||||||
cardNumber: card.cardNumber || '',
|
|
||||||
cardName: card.cardName || '',
|
|
||||||
expiryDate: card.expiryDate || '',
|
|
||||||
pinPrefix: '', // 비밀번호는 항상 빈 값
|
|
||||||
status: card.status || 'active',
|
|
||||||
userId: card.user?.id || '',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 제출 데이터 변환 (formData → API 요청)
|
|
||||||
transformSubmitData: (formData: Record<string, unknown>): Partial<CardFormData> => ({
|
|
||||||
cardCompany: formData.cardCompany as CardCompany,
|
|
||||||
cardNumber: formData.cardNumber as string,
|
|
||||||
cardName: formData.cardName as string,
|
|
||||||
expiryDate: formData.expiryDate as string,
|
|
||||||
pinPrefix: formData.pinPrefix as string,
|
|
||||||
status: formData.status as CardStatus,
|
|
||||||
userId: formData.userId as string,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// View 모드 값 포맷터
|
|
||||||
formatViewValue: (key: string, value: unknown, data: Record<string, unknown>) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'cardCompany':
|
|
||||||
return getCardCompanyLabel(value as CardCompany);
|
|
||||||
case 'expiryDate':
|
|
||||||
// MMYY → MM/YY
|
|
||||||
const date = value as string;
|
|
||||||
if (date && date.length === 4) {
|
|
||||||
return `${date.slice(0, 2)}/${date.slice(2)}`;
|
|
||||||
}
|
|
||||||
return date || '-';
|
|
||||||
case 'userId':
|
|
||||||
// 사용자 정보 조합 표시
|
|
||||||
const userData = data as unknown as Card;
|
|
||||||
if (userData.user) {
|
|
||||||
return `${userData.user.departmentName} / ${userData.user.employeeName} / ${userData.user.positionName}`;
|
|
||||||
}
|
|
||||||
return '미지정';
|
|
||||||
default:
|
|
||||||
return undefined; // 기본 렌더링 사용
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,358 +1,303 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
/**
|
||||||
|
* 카드관리 - 카드 목록 페이지 (UniversalListPage 공통 구조)
|
||||||
|
*
|
||||||
|
* - 달력 + 프리셋 버튼 (이번달, 지난달, D-2월~D-5월)
|
||||||
|
* - 통계카드 4개 (전체/결제예정/총한도/잔여한도)
|
||||||
|
* - 수기 카드 등록 버튼
|
||||||
|
* - 카드사/상태 필터 (테이블 카드 내부)
|
||||||
|
* - 범례 (수기/연동 카드)
|
||||||
|
* - 체크박스 없음
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { CreditCard, Plus, Search, RefreshCw } from 'lucide-react';
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
import { Input } from '@/components/ui/input';
|
import { CreditCard, Wallet, PiggyBank, TrendingDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
UniversalListPage,
|
UniversalListPage,
|
||||||
type UniversalListConfig,
|
type UniversalListConfig,
|
||||||
type TabOption,
|
type SelectionHandlers,
|
||||||
|
type RowClickHandlers,
|
||||||
|
type ListParams,
|
||||||
|
type StatCard,
|
||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { toast } from 'sonner';
|
import type { Card as CardType } from './types';
|
||||||
import type { Card } from './types';
|
|
||||||
import {
|
import {
|
||||||
|
CARD_COMPANIES,
|
||||||
|
CARD_STATUS_OPTIONS,
|
||||||
CARD_STATUS_LABELS,
|
CARD_STATUS_LABELS,
|
||||||
CARD_STATUS_COLORS,
|
CARD_STATUS_COLORS,
|
||||||
getCardCompanyLabel,
|
getCardCompanyLabel,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { getCards, deleteCard, deleteCards } from './actions';
|
import { getCards, getCardStats } from './actions';
|
||||||
|
|
||||||
// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234)
|
function formatCurrency(value: number): string {
|
||||||
const maskCardNumber = (cardNumber: string): string => {
|
return value.toLocaleString('ko-KR') + '원';
|
||||||
return cardNumber;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CardManagementProps {
|
|
||||||
initialData?: Card[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardManagement({ initialData }: CardManagementProps) {
|
export function CardManagement() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 카드 데이터 상태
|
|
||||||
const [cards, setCards] = useState<Card[]>(initialData || []);
|
|
||||||
const [isLoading, setIsLoading] = useState(!initialData);
|
|
||||||
const isInitialLoadDone = useRef(false);
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialData) {
|
|
||||||
loadCards();
|
|
||||||
}
|
|
||||||
}, [initialData]);
|
|
||||||
|
|
||||||
const loadCards = async () => {
|
|
||||||
if (!isInitialLoadDone.current) {
|
|
||||||
setIsLoading(true);
|
|
||||||
}
|
|
||||||
const result = await getCards({ per_page: 100 });
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setCards(result.data);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '카드 목록을 불러오는데 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
isInitialLoadDone.current = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색 및 필터 상태
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('all');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
// 다이얼로그 상태
|
// ===== 날짜 범위 상태 =====
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const today = new Date();
|
||||||
const [cardToDelete, setCardToDelete] = useState<Card | null>(null);
|
const [startDate, setStartDate] = useState(() => format(startOfMonth(today), 'yyyy-MM-dd'));
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
const [endDate, setEndDate] = useState(() => format(endOfMonth(today), 'yyyy-MM-dd'));
|
||||||
|
|
||||||
// 필터링된 데이터
|
// ===== 필터 상태 =====
|
||||||
const filteredCards = useMemo(() => {
|
const [cardCompanyFilter, setCardCompanyFilter] = useState('all');
|
||||||
let filtered = cards;
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
|
||||||
// 탭 필터 (상태)
|
// ===== 통계 (별도 API) =====
|
||||||
if (activeTab !== 'all') {
|
const [stats, setStats] = useState<StatCard[]>([]);
|
||||||
filtered = filtered.filter(c => c.status === activeTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색 필터
|
useEffect(() => {
|
||||||
if (searchQuery) {
|
getCardStats({ startDate, endDate }).then(result => {
|
||||||
const search = searchQuery.toLowerCase();
|
if (result.success && result.data) {
|
||||||
filtered = filtered.filter(c =>
|
setStats([
|
||||||
c.cardName.toLowerCase().includes(search) ||
|
{ label: '전체', value: `${result.data.totalCount}개`, icon: CreditCard, iconColor: 'text-blue-500' },
|
||||||
c.cardNumber.includes(search) ||
|
{ label: '결제예정', value: formatCurrency(result.data.upcomingPayment), icon: Wallet, iconColor: 'text-orange-500' },
|
||||||
getCardCompanyLabel(c.cardCompany).toLowerCase().includes(search) ||
|
{ label: '총한도', value: formatCurrency(result.data.totalLimit), icon: PiggyBank, iconColor: 'text-green-500' },
|
||||||
c.user?.employeeName.toLowerCase().includes(search)
|
{ label: '잔여한도', value: formatCurrency(result.data.remainingLimit), icon: TrendingDown, iconColor: 'text-purple-500' },
|
||||||
);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [cards, activeTab, searchQuery]);
|
|
||||||
|
|
||||||
// 페이지네이션된 데이터
|
|
||||||
const paginatedData = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
return filteredCards.slice(startIndex, startIndex + itemsPerPage);
|
|
||||||
}, [filteredCards, currentPage, itemsPerPage]);
|
|
||||||
|
|
||||||
// 통계 계산
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
const activeCount = cards.filter(c => c.status === 'active').length;
|
|
||||||
const suspendedCount = cards.filter(c => c.status === 'suspended').length;
|
|
||||||
return { activeCount, suspendedCount };
|
|
||||||
}, [cards]);
|
|
||||||
|
|
||||||
// 탭 옵션
|
|
||||||
const tabs: TabOption[] = useMemo(() => [
|
|
||||||
{ value: 'all', label: '전체', count: cards.length, color: 'gray' },
|
|
||||||
{ value: 'active', label: '사용', count: stats.activeCount, color: 'green' },
|
|
||||||
{ value: 'suspended', label: '정지', count: stats.suspendedCount, color: 'red' },
|
|
||||||
], [cards.length, stats]);
|
|
||||||
|
|
||||||
// 테이블 컬럼 정의
|
|
||||||
const tableColumns = useMemo(() => [
|
|
||||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
|
||||||
{ key: 'cardCompany', label: '카드사', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
|
|
||||||
{ key: 'cardName', label: '카드명', className: 'min-w-[120px]' },
|
|
||||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
|
||||||
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'userName', label: '사용자', className: 'min-w-[100px]' },
|
|
||||||
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
|
|
||||||
], []);
|
|
||||||
|
|
||||||
// 체크박스 토글
|
|
||||||
const toggleSelection = useCallback((id: string) => {
|
|
||||||
setSelectedItems(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(id)) {
|
|
||||||
newSet.delete(id);
|
|
||||||
} else {
|
|
||||||
newSet.add(id);
|
|
||||||
}
|
}
|
||||||
return newSet;
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
// 전체 선택/해제
|
// ===== 핸들러 =====
|
||||||
const toggleSelectAll = useCallback(() => {
|
const handleRowClick = useCallback((item: CardType) => {
|
||||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
router.push(`/ko/hr/card-management/${item.id}`);
|
||||||
setSelectedItems(new Set());
|
}, [router]);
|
||||||
} else {
|
|
||||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
|
||||||
setSelectedItems(allIds);
|
|
||||||
}
|
|
||||||
}, [selectedItems.size, paginatedData]);
|
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
const handleCreate = useCallback(() => {
|
||||||
const handleBulkDelete = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedItems);
|
|
||||||
const result = await deleteCards(ids);
|
|
||||||
if (result.success) {
|
|
||||||
setCards(prev => prev.filter(card => !ids.includes(card.id)));
|
|
||||||
setSelectedItems(new Set());
|
|
||||||
toast.success('선택한 카드가 삭제되었습니다.');
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
}, [selectedItems]);
|
|
||||||
|
|
||||||
// 핸들러
|
|
||||||
const handleAddCard = useCallback(() => {
|
|
||||||
router.push('/ko/hr/card-management?mode=new');
|
router.push('/ko/hr/card-management?mode=new');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleDeleteCard = useCallback(async () => {
|
// ===== Config =====
|
||||||
if (cardToDelete) {
|
const config: UniversalListConfig<CardType> = useMemo(
|
||||||
const result = await deleteCard(cardToDelete.id);
|
() => ({
|
||||||
if (result.success) {
|
title: '카드 관리',
|
||||||
setCards(prev => prev.filter(card => card.id !== cardToDelete.id));
|
description: '관련 기능 및 카드 목록을 관리합니다.',
|
||||||
toast.success('카드가 삭제되었습니다.');
|
icon: CreditCard,
|
||||||
} else {
|
basePath: '/hr/card-management',
|
||||||
toast.error(result.error || '삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setCardToDelete(null);
|
|
||||||
}
|
|
||||||
}, [cardToDelete]);
|
|
||||||
|
|
||||||
const handleRowClick = useCallback((row: Card) => {
|
idField: 'id',
|
||||||
router.push(`/ko/hr/card-management/${row.id}?mode=view`);
|
showCheckbox: false,
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// ===== UniversalListPage 설정 =====
|
// 날짜 범위 선택기 + 프리셋 버튼
|
||||||
const cardManagementConfig: UniversalListConfig<Card> = useMemo(() => ({
|
dateRangeSelector: {
|
||||||
title: '카드관리',
|
enabled: true,
|
||||||
description: '카드 목록을 관리합니다',
|
showPresets: true,
|
||||||
icon: CreditCard,
|
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||||
basePath: '/hr/card-management',
|
presetLabels: {
|
||||||
|
thisMonth: '이번달',
|
||||||
idField: 'id',
|
lastMonth: '지난달',
|
||||||
|
},
|
||||||
actions: {
|
startDate,
|
||||||
getList: async () => ({
|
endDate,
|
||||||
success: true,
|
onStartDateChange: setStartDate,
|
||||||
data: cards,
|
onEndDateChange: setEndDate,
|
||||||
totalCount: cards.length,
|
|
||||||
}),
|
|
||||||
deleteBulk: async (ids) => {
|
|
||||||
const result = await deleteCards(ids);
|
|
||||||
if (result.success) {
|
|
||||||
setCards(prev => prev.filter(card => !ids.includes(card.id)));
|
|
||||||
setSelectedItems(new Set());
|
|
||||||
toast.success('선택한 카드가 삭제되었습니다.');
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
|
|
||||||
columns: tableColumns,
|
// 수기 카드 등록 버튼
|
||||||
|
createButton: {
|
||||||
|
label: '수기 카드 등록',
|
||||||
|
onClick: handleCreate,
|
||||||
|
},
|
||||||
|
|
||||||
tabs: tabs,
|
// 통계카드 (별도 API에서 로드)
|
||||||
defaultTab: activeTab,
|
stats,
|
||||||
|
|
||||||
createButton: {
|
// API 액션
|
||||||
label: '카드 등록',
|
actions: {
|
||||||
icon: Plus,
|
getList: async (params?: ListParams) => {
|
||||||
onClick: handleAddCard,
|
try {
|
||||||
},
|
const result = await getCards({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
cardCompany: cardCompanyFilter,
|
||||||
|
status: statusFilter,
|
||||||
|
search: params?.search || '',
|
||||||
|
page: params?.page || 1,
|
||||||
|
per_page: itemsPerPage,
|
||||||
|
});
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
totalCount: result.pagination?.total || result.data.length,
|
||||||
|
totalPages: result.pagination?.lastPage || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
searchPlaceholder: '카드명, 카드번호, 카드사, 사용자 검색...',
|
// 테이블 컬럼
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||||
|
{ key: 'cardCompany', label: '카드사', className: 'min-w-[90px]' },
|
||||||
|
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
|
||||||
|
{ key: 'cardName', label: '카드명', className: 'min-w-[150px]' },
|
||||||
|
{ key: 'department', label: '부서', className: 'min-w-[80px]' },
|
||||||
|
{ key: 'user', label: '사용자', className: 'min-w-[80px]' },
|
||||||
|
{ key: 'usage', label: '사용현황', className: 'min-w-[180px]' },
|
||||||
|
{ key: 'status', label: '상태', className: 'text-center min-w-[70px]' },
|
||||||
|
],
|
||||||
|
|
||||||
itemsPerPage: itemsPerPage,
|
itemsPerPage,
|
||||||
|
searchPlaceholder: '카드명, 카드번호, 사용자명 검색...',
|
||||||
|
|
||||||
clientSideFiltering: true,
|
// 테이블 카드 내부 필터 (카드사, 상태)
|
||||||
|
tableHeaderActions: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={cardCompanyFilter} onValueChange={setCardCompanyFilter}>
|
||||||
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
|
<SelectValue placeholder="카드사" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 카드사</SelectItem>
|
||||||
|
{CARD_COMPANIES.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[100px] h-9">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CARD_STATUS_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
searchFilter: (item, searchValue) => {
|
// 테이블 행 렌더링
|
||||||
const search = searchValue.toLowerCase();
|
renderTableRow: (
|
||||||
return (
|
item: CardType,
|
||||||
item.cardName.toLowerCase().includes(search) ||
|
_index: number,
|
||||||
item.cardNumber.includes(search) ||
|
globalIndex: number,
|
||||||
getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) ||
|
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
||||||
(item.user?.employeeName?.toLowerCase().includes(search) ?? false)
|
) => {
|
||||||
);
|
const usagePercent = item.totalLimit > 0
|
||||||
},
|
? Math.min(Math.round((item.usedAmount / item.totalLimit) * 100), 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
tabFilter: (item, activeTab) => {
|
return (
|
||||||
if (activeTab === 'all') return true;
|
<TableRow
|
||||||
return item.status === activeTab;
|
key={item.id}
|
||||||
},
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell className="text-sm">{getCardCompanyLabel(item.cardCompany)}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{item.cardNumber}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{item.cardName}
|
||||||
|
{item.isManual ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">(수기)</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-blue-500">(연동)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.user?.departmentName || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.user?.employeeName || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-red-600">{formatCurrency(item.usedAmount)}</span>
|
||||||
|
<span className="text-muted-foreground">{usagePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full transition-all"
|
||||||
|
style={{ width: `${usagePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
||||||
|
{CARD_STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
// 모바일 카드
|
||||||
const { isSelected, onToggle, onRowClick } = handlers;
|
renderMobileCard: (
|
||||||
|
item: CardType,
|
||||||
return (
|
_index: number,
|
||||||
<TableRow
|
_globalIndex: number,
|
||||||
key={item.id}
|
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
||||||
className="hover:bg-muted/50 cursor-pointer"
|
) => (
|
||||||
onClick={() => handleRowClick(item)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={onToggle}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-center">
|
|
||||||
{globalIndex}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{getCardCompanyLabel(item.cardCompany)}</TableCell>
|
|
||||||
<TableCell className="font-mono">{maskCardNumber(item.cardNumber)}</TableCell>
|
|
||||||
<TableCell>{item.cardName}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
|
||||||
{CARD_STATUS_LABELS[item.status]}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{item.user?.departmentName || '-'}</TableCell>
|
|
||||||
<TableCell>{item.user?.employeeName || '-'}</TableCell>
|
|
||||||
<TableCell>{item.user?.positionName || '-'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
|
||||||
const { isSelected, onToggle } = handlers;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListMobileCard
|
<ListMobileCard
|
||||||
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
title={item.cardName}
|
title={item.cardName}
|
||||||
headerBadges={
|
headerBadges={
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<span className="text-xs text-muted-foreground">
|
||||||
<Badge variant="outline" className="text-xs">
|
{getCardCompanyLabel(item.cardCompany)}
|
||||||
#{globalIndex}
|
</span>
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{getCardCompanyLabel(item.cardCompany)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
statusBadge={
|
statusBadge={
|
||||||
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
||||||
{CARD_STATUS_LABELS[item.status]}
|
{CARD_STATUS_LABELS[item.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
isSelected={isSelected}
|
isSelected={false}
|
||||||
onToggleSelection={onToggle}
|
onToggleSelection={() => {}}
|
||||||
onCardClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
infoGrid={
|
infoGrid={
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<InfoField label="카드번호" value={maskCardNumber(item.cardNumber)} />
|
<InfoField label="카드번호" value={item.cardNumber} />
|
||||||
<InfoField label="유효기간" value={`${item.expiryDate.slice(0, 2)}/${item.expiryDate.slice(2)}`} />
|
|
||||||
<InfoField label="부서" value={item.user?.departmentName || '-'} />
|
|
||||||
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
||||||
<InfoField label="직책" value={item.user?.positionName || '-'} />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
|
|
||||||
renderDialogs: () => (
|
// 테이블 카드 내부 하단 - 범례
|
||||||
<DeleteConfirmDialog
|
tableFooter: (
|
||||||
open={deleteDialogOpen}
|
<TableRow>
|
||||||
onOpenChange={setDeleteDialogOpen}
|
<TableCell colSpan={8} className="border-0">
|
||||||
onConfirm={handleDeleteCard}
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
title="카드 삭제"
|
<div className="flex items-center gap-1.5">
|
||||||
description={
|
<span className="inline-block w-2 h-2 rounded-full bg-gray-400" />
|
||||||
<>
|
수기 카드
|
||||||
"{cardToDelete?.cardName}" 카드를 삭제하시겠습니까?
|
</div>
|
||||||
<br />
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-destructive">
|
<span className="inline-block w-2 h-2 rounded-full bg-blue-500" />
|
||||||
삭제된 카드 정보는 복구할 수 없습니다.
|
연동 카드
|
||||||
</span>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
</TableCell>
|
||||||
/>
|
</TableRow>
|
||||||
),
|
),
|
||||||
}), [
|
}),
|
||||||
cards,
|
[handleCreate, handleRowClick, startDate, endDate, cardCompanyFilter, statusFilter, stats]
|
||||||
tableColumns,
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
handleAddCard,
|
|
||||||
handleRowClick,
|
|
||||||
deleteDialogOpen,
|
|
||||||
cardToDelete,
|
|
||||||
handleDeleteCard,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UniversalListPage<Card>
|
|
||||||
config={cardManagementConfig}
|
|
||||||
initialData={cards}
|
|
||||||
initialTotalCount={cards.length}
|
|
||||||
externalIsLoading={isLoading}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
return <UniversalListPage config={config} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
// 카드 상태
|
// ===== 카드 상태 =====
|
||||||
export type CardStatus = 'active' | 'suspended';
|
export type CardStatus = 'active' | 'suspended';
|
||||||
|
|
||||||
// 카드 상태 레이블
|
|
||||||
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
|
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
|
||||||
active: '사용',
|
active: '사용',
|
||||||
suspended: '정지',
|
suspended: '중지',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카드 상태 색상
|
|
||||||
export const CARD_STATUS_COLORS: Record<CardStatus, string> = {
|
export const CARD_STATUS_COLORS: Record<CardStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800',
|
active: 'bg-green-100 text-green-800',
|
||||||
suspended: 'bg-red-100 text-red-800',
|
suspended: 'bg-red-100 text-red-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카드사 목록
|
export const CARD_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'active', label: '사용' },
|
||||||
|
{ value: 'suspended', label: '중지' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ===== 카드사 목록 =====
|
||||||
export const CARD_COMPANIES = [
|
export const CARD_COMPANIES = [
|
||||||
{ value: 'shinhan', label: '신한카드' },
|
{ value: 'shinhan', label: '신한카드' },
|
||||||
{ value: 'kb', label: 'KB국민카드' },
|
{ value: 'kb', label: 'KB국민카드' },
|
||||||
@@ -29,7 +33,46 @@ export const CARD_COMPANIES = [
|
|||||||
|
|
||||||
export type CardCompany = typeof CARD_COMPANIES[number]['value'];
|
export type CardCompany = typeof CARD_COMPANIES[number]['value'];
|
||||||
|
|
||||||
// 카드 사용자 정보
|
export const getCardCompanyLabel = (value: CardCompany | string): string => {
|
||||||
|
const company = CARD_COMPANIES.find(c => c.value === value);
|
||||||
|
return company?.label || value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 카드 종류 (법인/개인 등) =====
|
||||||
|
export const CARD_TYPE_OPTIONS = [
|
||||||
|
{ value: 'corporate_1', label: '법인 1종' },
|
||||||
|
{ value: 'corporate_5', label: '법인 5인' },
|
||||||
|
{ value: 'corporate_10', label: '법인 10인' },
|
||||||
|
{ value: 'corporate_14', label: '법인 14인' },
|
||||||
|
{ value: 'corporate_15_plus', label: '법인 15인 이상' },
|
||||||
|
{ value: 'corporate_25', label: '법인 25인' },
|
||||||
|
{ value: 'corporate_27', label: '법인 27인' },
|
||||||
|
{ value: 'personal', label: '개인카드' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ===== 결제일 옵션 =====
|
||||||
|
export const PAYMENT_DAY_OPTIONS = [
|
||||||
|
{ value: '1', label: '매월 1일' },
|
||||||
|
{ value: '5', label: '매월 5일' },
|
||||||
|
{ value: '10', label: '매월 10일' },
|
||||||
|
{ value: '14', label: '매월 14일' },
|
||||||
|
{ value: '15', label: '매월 15일' },
|
||||||
|
{ value: '20', label: '매월 20일' },
|
||||||
|
{ value: '25', label: '매월 25일' },
|
||||||
|
{ value: '27', label: '매월 27일' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ===== 날짜 프리셋 =====
|
||||||
|
export const DATE_PRESETS = [
|
||||||
|
{ label: '이번달', value: 0 },
|
||||||
|
{ label: '기본 날짜', value: -1 },
|
||||||
|
{ label: 'D-2일', value: -2 },
|
||||||
|
{ label: 'D-3일', value: -3 },
|
||||||
|
{ label: 'D-4일', value: -4 },
|
||||||
|
{ label: 'D-5일', value: -5 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ===== 카드 사용자 정보 =====
|
||||||
export interface CardUser {
|
export interface CardUser {
|
||||||
id: string;
|
id: string;
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
@@ -40,33 +83,64 @@ export interface CardUser {
|
|||||||
positionName: string;
|
positionName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 정보
|
// ===== 카드 정보 =====
|
||||||
export interface Card {
|
export interface Card {
|
||||||
id: string;
|
id: string;
|
||||||
cardCompany: CardCompany;
|
cardCompany: CardCompany | string;
|
||||||
cardNumber: string; // 1234-1234-1234-1234
|
cardType: string;
|
||||||
cardName: string; // 카드명
|
cardNumber: string;
|
||||||
expiryDate: string; // MMYY
|
cardName: string;
|
||||||
pinPrefix: string; // 비밀번호 앞 2자리
|
alias: string;
|
||||||
|
expiryDate: string;
|
||||||
|
csv: string;
|
||||||
|
paymentDay: string;
|
||||||
|
pinPrefix: string;
|
||||||
|
totalLimit: number;
|
||||||
|
usedAmount: number;
|
||||||
|
remainingLimit: number;
|
||||||
status: CardStatus;
|
status: CardStatus;
|
||||||
|
isManual: boolean;
|
||||||
user?: CardUser;
|
user?: CardUser;
|
||||||
|
memo: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 폼 데이터
|
// ===== 카드 폼 데이터 =====
|
||||||
export interface CardFormData {
|
export interface CardFormData {
|
||||||
cardCompany: CardCompany | '';
|
cardCompany: CardCompany | string;
|
||||||
|
cardType: string;
|
||||||
cardNumber: string;
|
cardNumber: string;
|
||||||
cardName: string;
|
cardName: string;
|
||||||
|
alias: string;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
|
csv: string;
|
||||||
|
paymentDay: string;
|
||||||
pinPrefix: string;
|
pinPrefix: string;
|
||||||
|
totalLimit: number;
|
||||||
|
usedAmount: number;
|
||||||
|
remainingLimit: number;
|
||||||
status: CardStatus;
|
status: CardStatus;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
departmentId?: string;
|
||||||
|
positionId?: string;
|
||||||
|
memo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드사 레이블 가져오기
|
// ===== 통계 =====
|
||||||
export const getCardCompanyLabel = (value: CardCompany): string => {
|
export interface CardStats {
|
||||||
const company = CARD_COMPANIES.find(c => c.value === value);
|
totalCount: number;
|
||||||
return company?.label || value;
|
upcomingPayment: number;
|
||||||
};
|
totalLimit: number;
|
||||||
|
remainingLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 리스트 필터 =====
|
||||||
|
export interface CardListFilter {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
cardCompany: string;
|
||||||
|
status: string;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup'
|
|||||||
/**
|
/**
|
||||||
* 날짜 범위 프리셋 타입
|
* 날짜 범위 프리셋 타입
|
||||||
*/
|
*/
|
||||||
export type DatePreset = 'thisYear' | 'twoMonthsAgo' | 'lastMonth' | 'thisMonth' | 'yesterday' | 'today';
|
export type DatePreset = 'thisYear' | 'fiveMonthsAgo' | 'fourMonthsAgo' | 'threeMonthsAgo' | 'twoMonthsAgo' | 'lastMonth' | 'thisMonth' | 'yesterday' | 'today';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프리셋 레이블 (한국어)
|
* 프리셋 레이블 (한국어)
|
||||||
*/
|
*/
|
||||||
const PRESET_LABELS: Record<DatePreset, string> = {
|
const PRESET_LABELS: Record<DatePreset, string> = {
|
||||||
thisYear: '당해년도',
|
thisYear: '당해년도',
|
||||||
|
fiveMonthsAgo: 'D-5월',
|
||||||
|
fourMonthsAgo: 'D-4월',
|
||||||
|
threeMonthsAgo: 'D-3월',
|
||||||
twoMonthsAgo: '전전월',
|
twoMonthsAgo: '전전월',
|
||||||
lastMonth: '전월',
|
lastMonth: '전월',
|
||||||
thisMonth: '당월',
|
thisMonth: '당월',
|
||||||
@@ -39,6 +42,8 @@ interface DateRangeSelectorProps {
|
|||||||
onEndDateChange: (date: string) => void;
|
onEndDateChange: (date: string) => void;
|
||||||
/** 표시할 프리셋 목록 (기본: 전체) */
|
/** 표시할 프리셋 목록 (기본: 전체) */
|
||||||
presets?: DatePreset[];
|
presets?: DatePreset[];
|
||||||
|
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||||
|
presetLabels?: Partial<Record<DatePreset, string>>;
|
||||||
/** 추가 액션 (엑셀 다운로드, 등록 버튼 등) */
|
/** 추가 액션 (엑셀 다운로드, 등록 버튼 등) */
|
||||||
extraActions?: ReactNode;
|
extraActions?: ReactNode;
|
||||||
/** 프리셋 버튼 숨김 */
|
/** 프리셋 버튼 숨김 */
|
||||||
@@ -78,6 +83,7 @@ export function DateRangeSelector({
|
|||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
presets = DEFAULT_PRESETS,
|
presets = DEFAULT_PRESETS,
|
||||||
|
presetLabels: customLabels,
|
||||||
extraActions,
|
extraActions,
|
||||||
hidePresets = false,
|
hidePresets = false,
|
||||||
hideDateInputs = false,
|
hideDateInputs = false,
|
||||||
@@ -120,6 +126,24 @@ export function DateRangeSelector({
|
|||||||
onStartDateChange(format(today, 'yyyy-MM-dd'));
|
onStartDateChange(format(today, 'yyyy-MM-dd'));
|
||||||
onEndDateChange(format(today, 'yyyy-MM-dd'));
|
onEndDateChange(format(today, 'yyyy-MM-dd'));
|
||||||
break;
|
break;
|
||||||
|
case 'threeMonthsAgo': {
|
||||||
|
const threeMonthsAgo = subMonths(today, 3);
|
||||||
|
onStartDateChange(format(startOfMonth(threeMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
onEndDateChange(format(endOfMonth(threeMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fourMonthsAgo': {
|
||||||
|
const fourMonthsAgo = subMonths(today, 4);
|
||||||
|
onStartDateChange(format(startOfMonth(fourMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
onEndDateChange(format(endOfMonth(fourMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'fiveMonthsAgo': {
|
||||||
|
const fiveMonthsAgo = subMonths(today, 5);
|
||||||
|
onStartDateChange(format(startOfMonth(fiveMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
onEndDateChange(format(endOfMonth(fiveMonthsAgo), 'yyyy-MM-dd'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [onStartDateChange, onEndDateChange]);
|
}, [onStartDateChange, onEndDateChange]);
|
||||||
|
|
||||||
@@ -136,7 +160,7 @@ export function DateRangeSelector({
|
|||||||
onClick={() => handlePresetClick(preset)}
|
onClick={() => handlePresetClick(preset)}
|
||||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||||
>
|
>
|
||||||
{PRESET_LABELS[preset]}
|
{customLabels?.[preset] || PRESET_LABELS[preset]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</ScrollableButtonGroup>
|
</ScrollableButtonGroup>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LucideIcon } from "lucide-react";
|
|||||||
|
|
||||||
interface StatCardData {
|
interface StatCardData {
|
||||||
label: string;
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
@@ -21,8 +22,14 @@ interface StatCardsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatCards({ stats }: StatCardsProps) {
|
export function StatCards({ stats }: StatCardsProps) {
|
||||||
|
const count = stats.length;
|
||||||
|
const gridClass =
|
||||||
|
count >= 5
|
||||||
|
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2'
|
||||||
|
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 gap-2">
|
<div className={gridClass}>
|
||||||
{stats.map((stat, index) => {
|
{stats.map((stat, index) => {
|
||||||
const Icon = stat.icon;
|
const Icon = stat.icon;
|
||||||
const isClickable = !!stat.onClick;
|
const isClickable = !!stat.onClick;
|
||||||
@@ -42,6 +49,9 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
|
{stat.sublabel && (
|
||||||
|
<span className="ml-2 normal-case tracking-normal">{stat.sublabel}</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-bold text-base md:text-lg truncate">
|
<p className="font-bold text-base md:text-lg truncate">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
|||||||
|
|
||||||
// 폼 상태
|
// 폼 상태
|
||||||
const [formData, setFormData] = useState<AccountFormData>({
|
const [formData, setFormData] = useState<AccountFormData>({
|
||||||
|
category: account?.category || 'bank_account',
|
||||||
|
accountType: account?.accountType || 'savings',
|
||||||
bankCode: account?.bankCode || '',
|
bankCode: account?.bankCode || '',
|
||||||
bankName: account?.bankName || '',
|
bankName: account?.bankName || '',
|
||||||
accountNumber: account?.accountNumber || '',
|
accountNumber: account?.accountNumber || '',
|
||||||
accountName: account?.accountName || '',
|
accountName: account?.accountName || '',
|
||||||
accountHolder: account?.accountHolder || '',
|
accountHolder: account?.accountHolder || '',
|
||||||
accountPassword: '',
|
|
||||||
status: account?.status || 'active',
|
status: account?.status || 'active',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,11 +122,12 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
|||||||
// 원래 데이터로 복원
|
// 원래 데이터로 복원
|
||||||
if (account) {
|
if (account) {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
category: account.category || 'bank_account',
|
||||||
|
accountType: account.accountType || 'savings',
|
||||||
bankCode: account.bankCode, bankName: account.bankName,
|
bankCode: account.bankCode, bankName: account.bankName,
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
accountHolder: account.accountHolder,
|
accountHolder: account.accountHolder,
|
||||||
accountPassword: '',
|
|
||||||
status: account.status,
|
status: account.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -292,15 +294,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="accountPassword">
|
<Label htmlFor="accountName">계좌명</Label>
|
||||||
계좌 비밀번호 (빠른 조회 서비스)
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="accountPassword"
|
id="accountName2"
|
||||||
type="password"
|
value={formData.accountName}
|
||||||
value={formData.accountPassword}
|
onChange={(e) => handleChange('accountName', e.target.value)}
|
||||||
onChange={(e) => handleChange('accountPassword', e.target.value)}
|
placeholder="계좌명"
|
||||||
placeholder="****"
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
814
src/components/settings/AccountManagement/AccountDetailForm.tsx
Normal file
814
src/components/settings/AccountManagement/AccountDetailForm.tsx
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccountDetailForm - 계좌 등록/수정/보기 조건부 폼
|
||||||
|
*
|
||||||
|
* 구분(category) 선택에 따라 유형(accountType) 옵션과 하단 상세 섹션이 동적 변경됨
|
||||||
|
* - 은행계좌: 계좌 정보 (계약금액, 이율, 시작일, 만기일, 이월잔액)
|
||||||
|
* - 대출계좌: 대출 정보 (대출금액, 이율, 상환방식, 거치기간 등)
|
||||||
|
* - 증권계좌: 증권 정보 (투자금액, 수익율, 평가액)
|
||||||
|
* - 보험계좌: 보험 정보 (단체/화재/CEO별 다른 필드)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Landmark, Save, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import type { Account, AccountCategory, AccountFormData } from './types';
|
||||||
|
import {
|
||||||
|
ACCOUNT_CATEGORY_OPTIONS,
|
||||||
|
ACCOUNT_TYPE_OPTIONS_BY_CATEGORY,
|
||||||
|
ACCOUNT_STATUS_OPTIONS,
|
||||||
|
FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY,
|
||||||
|
BANK_LABELS,
|
||||||
|
HOLDER_LABEL_BY_CATEGORY,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== Props =====
|
||||||
|
interface AccountDetailFormProps {
|
||||||
|
mode: 'create' | 'edit' | 'view';
|
||||||
|
initialData?: Account;
|
||||||
|
onSubmit: (data: AccountFormData) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
onDelete?: () => Promise<{ success: boolean; error?: string }>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 초기 폼 데이터 생성 =====
|
||||||
|
function getInitialFormData(initialData?: Account): AccountFormData {
|
||||||
|
if (initialData) {
|
||||||
|
return {
|
||||||
|
category: initialData.category || 'bank_account',
|
||||||
|
accountType: initialData.accountType || '',
|
||||||
|
bankCode: initialData.bankCode || '',
|
||||||
|
bankName: initialData.bankName || '',
|
||||||
|
accountNumber: initialData.accountNumber || '',
|
||||||
|
accountName: initialData.accountName || '',
|
||||||
|
accountHolder: initialData.accountHolder || '',
|
||||||
|
status: initialData.status || 'active',
|
||||||
|
contractAmount: initialData.contractAmount,
|
||||||
|
interestRate: initialData.interestRate,
|
||||||
|
startDate: initialData.startDate,
|
||||||
|
maturityDate: initialData.maturityDate,
|
||||||
|
carryoverBalance: initialData.carryoverBalance,
|
||||||
|
loanAmount: initialData.loanAmount,
|
||||||
|
loanBalance: initialData.loanBalance,
|
||||||
|
interestPaymentCycle: initialData.interestPaymentCycle,
|
||||||
|
repaymentMethod: initialData.repaymentMethod,
|
||||||
|
gracePeriod: initialData.gracePeriod,
|
||||||
|
monthlyRepayment: initialData.monthlyRepayment,
|
||||||
|
collateral: initialData.collateral,
|
||||||
|
investmentAmount: initialData.investmentAmount,
|
||||||
|
returnRate: initialData.returnRate,
|
||||||
|
evaluationAmount: initialData.evaluationAmount,
|
||||||
|
surrenderValue: initialData.surrenderValue,
|
||||||
|
policyNumber: initialData.policyNumber,
|
||||||
|
paymentCycle: initialData.paymentCycle,
|
||||||
|
premiumPerCycle: initialData.premiumPerCycle,
|
||||||
|
premiumPerPerson: initialData.premiumPerPerson,
|
||||||
|
enrolledCount: initialData.enrolledCount,
|
||||||
|
insuredProperty: initialData.insuredProperty,
|
||||||
|
propertyAddress: initialData.propertyAddress,
|
||||||
|
beneficiary: initialData.beneficiary,
|
||||||
|
note: initialData.note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category: 'bank_account',
|
||||||
|
accountType: '',
|
||||||
|
bankCode: '',
|
||||||
|
bankName: '',
|
||||||
|
accountNumber: '',
|
||||||
|
accountName: '',
|
||||||
|
accountHolder: '',
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountDetailForm({
|
||||||
|
mode: initialMode,
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
isLoading,
|
||||||
|
}: AccountDetailFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState(initialMode);
|
||||||
|
const [formData, setFormData] = useState<AccountFormData>(() => getInitialFormData(initialData));
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
const isViewMode = mode === 'view';
|
||||||
|
const isCreateMode = mode === 'create';
|
||||||
|
const disabled = isViewMode;
|
||||||
|
|
||||||
|
// ===== 구분별 동적 옵션 =====
|
||||||
|
const typeOptions = useMemo(
|
||||||
|
() => ACCOUNT_TYPE_OPTIONS_BY_CATEGORY[formData.category] || [],
|
||||||
|
[formData.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
const institutionOptions = useMemo(
|
||||||
|
() => FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY[formData.category] || [],
|
||||||
|
[formData.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
const holderLabel = HOLDER_LABEL_BY_CATEGORY[formData.category] || '예금주';
|
||||||
|
|
||||||
|
// ===== 핸들러 =====
|
||||||
|
const handleChange = useCallback((field: keyof AccountFormData, value: string | number | undefined) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategoryChange = useCallback((value: string) => {
|
||||||
|
const category = value as AccountCategory;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
category,
|
||||||
|
accountType: '',
|
||||||
|
bankCode: '',
|
||||||
|
bankName: '',
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBankCodeChange = useCallback((value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
bankCode: value,
|
||||||
|
bankName: BANK_LABELS[value] || value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.category || !formData.bankCode || !formData.accountNumber) {
|
||||||
|
toast.error('필수 항목을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await onSubmit(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(isCreateMode ? '계좌가 등록되었습니다.' : '계좌가 수정되었습니다.');
|
||||||
|
router.push('/ko/settings/accounts');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [formData, onSubmit, isCreateMode, router]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!onDelete) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await onDelete();
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('계좌가 삭제되었습니다.');
|
||||||
|
router.push('/ko/settings/accounts');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
}
|
||||||
|
}, [onDelete, router]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
router.push('/ko/settings/accounts');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setMode('edit');
|
||||||
|
if (initialData?.id) {
|
||||||
|
router.push(`/ko/settings/accounts/${initialData.id}?mode=edit`);
|
||||||
|
}
|
||||||
|
}, [initialData?.id, router]);
|
||||||
|
|
||||||
|
// ===== 로딩 =====
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-8 bg-muted rounded w-1/3" />
|
||||||
|
<div className="h-64 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 렌더링 =====
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={isCreateMode ? '수기 계좌 등록' : isViewMode ? '계좌 상세' : '계좌 수정'}
|
||||||
|
description="계좌 정보를 관리합니다"
|
||||||
|
icon={Landmark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ===== 기본 정보 ===== */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 구분 & 유형 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="구분"
|
||||||
|
type="select"
|
||||||
|
required
|
||||||
|
value={formData.category}
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
options={ACCOUNT_CATEGORY_OPTIONS}
|
||||||
|
selectPlaceholder="구분 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
key={`type-${formData.category}`}
|
||||||
|
label="유형"
|
||||||
|
type="select"
|
||||||
|
value={formData.accountType}
|
||||||
|
onChange={(v) => handleChange('accountType', v)}
|
||||||
|
options={typeOptions}
|
||||||
|
selectPlaceholder="유형 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 금융기관 & 계좌번호 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
key={`bank-${formData.category}`}
|
||||||
|
label="금융기관"
|
||||||
|
type="select"
|
||||||
|
required
|
||||||
|
value={formData.bankCode}
|
||||||
|
onChange={handleBankCodeChange}
|
||||||
|
options={institutionOptions}
|
||||||
|
selectPlaceholder="금융기관 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="계좌번호"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.accountNumber}
|
||||||
|
onChange={(v) => handleChange('accountNumber', v)}
|
||||||
|
placeholder="계좌번호 입력"
|
||||||
|
disabled={disabled || mode === 'edit'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 계좌명 & 예금주/계약자/피보험자 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="계좌명(상용명)"
|
||||||
|
type="text"
|
||||||
|
value={formData.accountName}
|
||||||
|
onChange={(v) => handleChange('accountName', v)}
|
||||||
|
placeholder="계좌명 입력"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label={holderLabel}
|
||||||
|
type="text"
|
||||||
|
value={formData.accountHolder}
|
||||||
|
onChange={(v) => handleChange('accountHolder', v)}
|
||||||
|
placeholder={`${holderLabel} 입력`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="상태"
|
||||||
|
type="select"
|
||||||
|
required
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(v) => handleChange('status', v)}
|
||||||
|
options={ACCOUNT_STATUS_OPTIONS}
|
||||||
|
selectPlaceholder="상태 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ===== 구분별 상세 정보 ===== */}
|
||||||
|
{formData.category === 'bank_account' && (
|
||||||
|
<BankAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
{formData.category === 'loan_account' && (
|
||||||
|
<LoanAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
{formData.category === 'securities_account' && (
|
||||||
|
<SecuritiesAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
{formData.category === 'insurance_account' && (
|
||||||
|
<InsuranceAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 하단 버튼 ===== */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
목록으로
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isViewMode ? (
|
||||||
|
<>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleEdit}>수정</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!isCreateMode && onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isCreateMode ? '등록' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 삭제 확인 ===== */}
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOpenChange={setShowDeleteDialog}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="계좌 삭제"
|
||||||
|
description="계좌를 정말 삭제하시겠습니까? 삭제된 계좌의 과거 사용 내역은 보존됩니다."
|
||||||
|
loading={isSaving}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 구분별 상세 섹션 컴포넌트
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
formData: AccountFormData;
|
||||||
|
onChange: (field: keyof AccountFormData, value: string | number | undefined) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 은행계좌 정보 =====
|
||||||
|
function BankAccountSection({ formData, onChange, disabled }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">계좌 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="계약금액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.contractAmount}
|
||||||
|
onChangeNumber={(v) => onChange('contractAmount', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="이율(%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.interestRate != null ? String(formData.interestRate) : ''}
|
||||||
|
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={disabled}
|
||||||
|
allowDecimal
|
||||||
|
decimalPlaces={2}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="시작일"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate || ''}
|
||||||
|
onChange={(v) => onChange('startDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="만기일"
|
||||||
|
type="date"
|
||||||
|
value={formData.maturityDate || ''}
|
||||||
|
onChange={(v) => onChange('maturityDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="이월잔액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.carryoverBalance}
|
||||||
|
onChangeNumber={(v) => onChange('carryoverBalance', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
type="text"
|
||||||
|
value={formData.note || ''}
|
||||||
|
onChange={(v) => onChange('note', v)}
|
||||||
|
placeholder="비고"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 대출계좌 정보 =====
|
||||||
|
function LoanAccountSection({ formData, onChange, disabled }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">대출 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="대출금액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.loanAmount}
|
||||||
|
onChangeNumber={(v) => onChange('loanAmount', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="이율(%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.interestRate != null ? String(formData.interestRate) : ''}
|
||||||
|
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={disabled}
|
||||||
|
allowDecimal
|
||||||
|
decimalPlaces={2}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="시작일"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate || ''}
|
||||||
|
onChange={(v) => onChange('startDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="만기일"
|
||||||
|
type="date"
|
||||||
|
value={formData.maturityDate || ''}
|
||||||
|
onChange={(v) => onChange('maturityDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="대출잔액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.loanBalance}
|
||||||
|
onChangeNumber={(v) => onChange('loanBalance', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="이자 납입 주기"
|
||||||
|
type="select"
|
||||||
|
value={formData.interestPaymentCycle || ''}
|
||||||
|
onChange={(v) => onChange('interestPaymentCycle', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'monthly', label: '월납' },
|
||||||
|
{ value: 'quarterly', label: '분기납' },
|
||||||
|
{ value: 'semi_annual', label: '반기납' },
|
||||||
|
{ value: 'annual', label: '연납' },
|
||||||
|
]}
|
||||||
|
selectPlaceholder="주기 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="상환 방식"
|
||||||
|
type="select"
|
||||||
|
value={formData.repaymentMethod || ''}
|
||||||
|
onChange={(v) => onChange('repaymentMethod', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'equal_principal', label: '원금균등' },
|
||||||
|
{ value: 'equal_installment', label: '원리금균등' },
|
||||||
|
{ value: 'bullet', label: '만기일시' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
]}
|
||||||
|
selectPlaceholder="방식 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="거치 기간"
|
||||||
|
type="text"
|
||||||
|
value={formData.gracePeriod || ''}
|
||||||
|
onChange={(v) => onChange('gracePeriod', v)}
|
||||||
|
placeholder="예: 6개월"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="월 상환액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.monthlyRepayment}
|
||||||
|
onChangeNumber={(v) => onChange('monthlyRepayment', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="담보물"
|
||||||
|
type="text"
|
||||||
|
value={formData.collateral || ''}
|
||||||
|
onChange={(v) => onChange('collateral', v)}
|
||||||
|
placeholder="담보물 입력"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
type="text"
|
||||||
|
value={formData.note || ''}
|
||||||
|
onChange={(v) => onChange('note', v)}
|
||||||
|
placeholder="비고"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 증권계좌 정보 =====
|
||||||
|
function SecuritiesAccountSection({ formData, onChange, disabled }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">증권 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="투자금액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.investmentAmount}
|
||||||
|
onChangeNumber={(v) => onChange('investmentAmount', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="수익율(%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.returnRate != null ? String(formData.returnRate) : ''}
|
||||||
|
onChange={(v) => onChange('returnRate', v ? Number(v) : undefined)}
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={disabled}
|
||||||
|
allowDecimal
|
||||||
|
decimalPlaces={2}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="시작일"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate || ''}
|
||||||
|
onChange={(v) => onChange('startDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="만기일"
|
||||||
|
type="date"
|
||||||
|
value={formData.maturityDate || ''}
|
||||||
|
onChange={(v) => onChange('maturityDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="평가액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.evaluationAmount}
|
||||||
|
onChangeNumber={(v) => onChange('evaluationAmount', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
type="text"
|
||||||
|
value={formData.note || ''}
|
||||||
|
onChange={(v) => onChange('note', v)}
|
||||||
|
placeholder="비고"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 보험계좌 정보 =====
|
||||||
|
function InsuranceAccountSection({ formData, onChange, disabled }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">보험 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 공통 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="계약금액"
|
||||||
|
type="currency"
|
||||||
|
value={formData.contractAmount}
|
||||||
|
onChangeNumber={(v) => onChange('contractAmount', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="이율(%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.interestRate != null ? String(formData.interestRate) : ''}
|
||||||
|
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={disabled}
|
||||||
|
allowDecimal
|
||||||
|
decimalPlaces={2}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="시작일"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate || ''}
|
||||||
|
onChange={(v) => onChange('startDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="만기일"
|
||||||
|
type="date"
|
||||||
|
value={formData.maturityDate || ''}
|
||||||
|
onChange={(v) => onChange('maturityDate', v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="해약환급금"
|
||||||
|
type="currency"
|
||||||
|
value={formData.surrenderValue}
|
||||||
|
onChangeNumber={(v) => onChange('surrenderValue', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="증권번호"
|
||||||
|
type="text"
|
||||||
|
value={formData.policyNumber || ''}
|
||||||
|
onChange={(v) => onChange('policyNumber', v)}
|
||||||
|
placeholder="증권번호"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="납입 주기"
|
||||||
|
type="select"
|
||||||
|
value={formData.paymentCycle || ''}
|
||||||
|
onChange={(v) => onChange('paymentCycle', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'monthly', label: '월납' },
|
||||||
|
{ value: 'quarterly', label: '분기납' },
|
||||||
|
{ value: 'semi_annual', label: '반기납' },
|
||||||
|
{ value: 'annual', label: '연납' },
|
||||||
|
{ value: 'lump_sum', label: '일시납' },
|
||||||
|
]}
|
||||||
|
selectPlaceholder="주기 선택"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="납입 주기당 보험료"
|
||||||
|
type="currency"
|
||||||
|
value={formData.premiumPerCycle}
|
||||||
|
onChangeNumber={(v) => onChange('premiumPerCycle', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단체보험 전용 */}
|
||||||
|
{formData.accountType === 'group_insurance' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="1인당 보험료"
|
||||||
|
type="currency"
|
||||||
|
value={formData.premiumPerPerson}
|
||||||
|
onChangeNumber={(v) => onChange('premiumPerPerson', v)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="가입 인원"
|
||||||
|
type="number"
|
||||||
|
value={formData.enrolledCount != null ? String(formData.enrolledCount) : ''}
|
||||||
|
onChange={(v) => onChange('enrolledCount', v ? Number(v) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화재보험 전용 */}
|
||||||
|
{formData.accountType === 'fire_insurance' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="보험 대상물"
|
||||||
|
type="text"
|
||||||
|
value={formData.insuredProperty || ''}
|
||||||
|
onChange={(v) => onChange('insuredProperty', v)}
|
||||||
|
placeholder="보험 대상물 입력"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="대상물 주소"
|
||||||
|
type="text"
|
||||||
|
value={formData.propertyAddress || ''}
|
||||||
|
onChange={(v) => onChange('propertyAddress', v)}
|
||||||
|
placeholder="주소 입력"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CEO보험 전용 */}
|
||||||
|
{formData.accountType === 'ceo_insurance' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="수익자"
|
||||||
|
type="text"
|
||||||
|
value={formData.beneficiary || ''}
|
||||||
|
onChange={(v) => onChange('beneficiary', v)}
|
||||||
|
placeholder="수익자 입력"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="비고"
|
||||||
|
type="text"
|
||||||
|
value={formData.note || ''}
|
||||||
|
onChange={(v) => onChange('note', v)}
|
||||||
|
placeholder="비고"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck - Legacy file, not in use
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export const accountConfig: DetailConfig<Account> = {
|
|||||||
accountNumber: formData.accountNumber as string,
|
accountNumber: formData.accountNumber as string,
|
||||||
accountName: formData.accountName as string,
|
accountName: formData.accountName as string,
|
||||||
accountHolder: formData.accountHolder as string,
|
accountHolder: formData.accountHolder as string,
|
||||||
accountPassword: formData.accountPassword as string,
|
|
||||||
status: formData.status as AccountStatus,
|
status: formData.status as AccountStatus,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server
|
|||||||
import { buildApiUrl } from '@/lib/api/query-params';
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import type { Account, AccountFormData, AccountStatus } from './types';
|
import type { Account, AccountCategory, AccountFormData, AccountStatus } from './types';
|
||||||
import { BANK_LABELS } from './types';
|
import { BANK_LABELS } from './types';
|
||||||
|
|
||||||
// ===== API 응답 타입 =====
|
// ===== API 응답 타입 =====
|
||||||
@@ -21,6 +21,35 @@ interface BankAccountApiData {
|
|||||||
assigned_user_id?: number;
|
assigned_user_id?: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
// 신규 필드
|
||||||
|
category?: AccountCategory;
|
||||||
|
account_type?: string;
|
||||||
|
is_manual?: boolean;
|
||||||
|
contract_amount?: number;
|
||||||
|
interest_rate?: number;
|
||||||
|
start_date?: string;
|
||||||
|
maturity_date?: string;
|
||||||
|
carryover_balance?: number;
|
||||||
|
loan_amount?: number;
|
||||||
|
loan_balance?: number;
|
||||||
|
interest_payment_cycle?: string;
|
||||||
|
repayment_method?: string;
|
||||||
|
grace_period?: string;
|
||||||
|
monthly_repayment?: number;
|
||||||
|
collateral?: string;
|
||||||
|
investment_amount?: number;
|
||||||
|
return_rate?: number;
|
||||||
|
evaluation_amount?: number;
|
||||||
|
surrender_value?: number;
|
||||||
|
policy_number?: string;
|
||||||
|
payment_cycle?: string;
|
||||||
|
premium_per_cycle?: number;
|
||||||
|
premium_per_person?: number;
|
||||||
|
enrolled_count?: number;
|
||||||
|
insured_property?: string;
|
||||||
|
property_address?: string;
|
||||||
|
beneficiary?: string;
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BankAccountPaginatedResponse = PaginatedApiResponse<BankAccountApiData>;
|
type BankAccountPaginatedResponse = PaginatedApiResponse<BankAccountApiData>;
|
||||||
@@ -39,23 +68,83 @@ function transformApiToFrontend(apiData: BankAccountApiData): Account {
|
|||||||
assignedUserId: apiData.assigned_user_id,
|
assignedUserId: apiData.assigned_user_id,
|
||||||
createdAt: apiData.created_at || '',
|
createdAt: apiData.created_at || '',
|
||||||
updatedAt: apiData.updated_at || '',
|
updatedAt: apiData.updated_at || '',
|
||||||
|
category: apiData.category || 'bank_account',
|
||||||
|
accountType: apiData.account_type || 'savings',
|
||||||
|
isManual: apiData.is_manual ?? true,
|
||||||
|
contractAmount: apiData.contract_amount,
|
||||||
|
interestRate: apiData.interest_rate,
|
||||||
|
startDate: apiData.start_date,
|
||||||
|
maturityDate: apiData.maturity_date,
|
||||||
|
carryoverBalance: apiData.carryover_balance,
|
||||||
|
loanAmount: apiData.loan_amount,
|
||||||
|
loanBalance: apiData.loan_balance,
|
||||||
|
interestPaymentCycle: apiData.interest_payment_cycle,
|
||||||
|
repaymentMethod: apiData.repayment_method,
|
||||||
|
gracePeriod: apiData.grace_period,
|
||||||
|
monthlyRepayment: apiData.monthly_repayment,
|
||||||
|
collateral: apiData.collateral,
|
||||||
|
investmentAmount: apiData.investment_amount,
|
||||||
|
returnRate: apiData.return_rate,
|
||||||
|
evaluationAmount: apiData.evaluation_amount,
|
||||||
|
surrenderValue: apiData.surrender_value,
|
||||||
|
policyNumber: apiData.policy_number,
|
||||||
|
paymentCycle: apiData.payment_cycle,
|
||||||
|
premiumPerCycle: apiData.premium_per_cycle,
|
||||||
|
premiumPerPerson: apiData.premium_per_person,
|
||||||
|
enrolledCount: apiData.enrolled_count,
|
||||||
|
insuredProperty: apiData.insured_property,
|
||||||
|
propertyAddress: apiData.property_address,
|
||||||
|
beneficiary: apiData.beneficiary,
|
||||||
|
note: apiData.note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformFrontendToApi(data: Partial<AccountFormData>): Record<string, unknown> {
|
function transformFrontendToApi(data: Partial<AccountFormData>): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
|
// 공통
|
||||||
|
category: data.category,
|
||||||
|
account_type: data.accountType,
|
||||||
bank_code: data.bankCode,
|
bank_code: data.bankCode,
|
||||||
bank_name: data.bankName || BANK_LABELS[data.bankCode || ''] || data.bankCode,
|
bank_name: data.bankName || BANK_LABELS[data.bankCode || ''] || data.bankCode,
|
||||||
account_number: data.accountNumber,
|
account_number: data.accountNumber,
|
||||||
account_holder: data.accountHolder,
|
account_holder: data.accountHolder,
|
||||||
account_name: data.accountName,
|
account_name: data.accountName,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
// 은행계좌
|
||||||
|
contract_amount: data.contractAmount,
|
||||||
|
interest_rate: data.interestRate,
|
||||||
|
start_date: data.startDate,
|
||||||
|
maturity_date: data.maturityDate,
|
||||||
|
carryover_balance: data.carryoverBalance,
|
||||||
|
// 대출계좌
|
||||||
|
loan_amount: data.loanAmount,
|
||||||
|
loan_balance: data.loanBalance,
|
||||||
|
interest_payment_cycle: data.interestPaymentCycle,
|
||||||
|
repayment_method: data.repaymentMethod,
|
||||||
|
grace_period: data.gracePeriod,
|
||||||
|
monthly_repayment: data.monthlyRepayment,
|
||||||
|
collateral: data.collateral,
|
||||||
|
// 증권계좌
|
||||||
|
investment_amount: data.investmentAmount,
|
||||||
|
return_rate: data.returnRate,
|
||||||
|
evaluation_amount: data.evaluationAmount,
|
||||||
|
// 보험계좌
|
||||||
|
surrender_value: data.surrenderValue,
|
||||||
|
policy_number: data.policyNumber,
|
||||||
|
payment_cycle: data.paymentCycle,
|
||||||
|
premium_per_cycle: data.premiumPerCycle,
|
||||||
|
premium_per_person: data.premiumPerPerson,
|
||||||
|
enrolled_count: data.enrolledCount,
|
||||||
|
insured_property: data.insuredProperty,
|
||||||
|
property_address: data.propertyAddress,
|
||||||
|
beneficiary: data.beneficiary,
|
||||||
|
note: data.note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 계좌 목록 조회 =====
|
// ===== 계좌 목록 조회 =====
|
||||||
export async function getBankAccounts(params?: {
|
export async function getBankAccounts(params?: {
|
||||||
page?: number; perPage?: number; search?: string;
|
page?: number; perPage?: number; search?: string; category?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean; data?: Account[]; meta?: { currentPage: number; lastPage: number; perPage: number; total: number };
|
success: boolean; data?: Account[]; meta?: { currentPage: number; lastPage: number; perPage: number; total: number };
|
||||||
error?: string; __authError?: boolean;
|
error?: string; __authError?: boolean;
|
||||||
@@ -65,6 +154,7 @@ export async function getBankAccounts(params?: {
|
|||||||
page: params?.page,
|
page: params?.page,
|
||||||
per_page: params?.perPage,
|
per_page: params?.perPage,
|
||||||
search: params?.search,
|
search: params?.search,
|
||||||
|
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||||
}),
|
}),
|
||||||
transform: (data: BankAccountPaginatedResponse) => ({
|
transform: (data: BankAccountPaginatedResponse) => ({
|
||||||
accounts: (data?.data || []).map(transformApiToFrontend),
|
accounts: (data?.data || []).map(transformApiToFrontend),
|
||||||
@@ -156,3 +246,28 @@ export async function deleteBankAccounts(ids: number[]): Promise<{
|
|||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 계좌 통계 (클라이언트 사이드 집계) =====
|
||||||
|
export async function getAccountSummary(): Promise<ActionResult<{
|
||||||
|
total: number;
|
||||||
|
bankAccount: number;
|
||||||
|
loanAccount: number;
|
||||||
|
securitiesAccount: number;
|
||||||
|
insuranceAccount: number;
|
||||||
|
}>> {
|
||||||
|
const result = await getBankAccounts({ perPage: 9999 });
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
const accounts = result.data;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total: accounts.length,
|
||||||
|
bankAccount: accounts.filter(a => a.category === 'bank_account').length,
|
||||||
|
loanAccount: accounts.filter(a => a.category === 'loan_account').length,
|
||||||
|
securitiesAccount: accounts.filter(a => a.category === 'securities_account').length,
|
||||||
|
insuranceAccount: accounts.filter(a => a.category === 'insurance_account').length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,174 +1,153 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계좌관리 - UniversalListPage 마이그레이션
|
* 계좌관리 - 종합 계좌 관리 목록 페이지
|
||||||
*
|
*
|
||||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
* - 통계카드 5개 (전체/은행/대출/증권/보험)
|
||||||
* - 클라이언트 사이드 필터링 (전체 데이터 로드 후 필터)
|
* - 구분/금융기관 필터
|
||||||
* - 삭제/일괄삭제 다이얼로그
|
* - 수기 계좌 등록 버튼
|
||||||
|
* - 범례 (수기/연동)
|
||||||
|
* - 체크박스 없음
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
Landmark,
|
Landmark,
|
||||||
Pencil,
|
Building2,
|
||||||
Trash2,
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
Shield,
|
||||||
Plus,
|
Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
UniversalListPage,
|
UniversalListPage,
|
||||||
type UniversalListConfig,
|
type UniversalListConfig,
|
||||||
type SelectionHandlers,
|
type SelectionHandlers,
|
||||||
type RowClickHandlers,
|
type RowClickHandlers,
|
||||||
type ListParams,
|
type ListParams,
|
||||||
|
type StatCard,
|
||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import type { Account } from './types';
|
import type { Account, AccountCategory } from './types';
|
||||||
import {
|
import {
|
||||||
BANK_LABELS,
|
BANK_LABELS,
|
||||||
|
ACCOUNT_CATEGORY_LABELS,
|
||||||
|
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||||
|
ACCOUNT_TYPE_LABELS,
|
||||||
ACCOUNT_STATUS_LABELS,
|
ACCOUNT_STATUS_LABELS,
|
||||||
ACCOUNT_STATUS_COLORS,
|
ACCOUNT_STATUS_COLORS,
|
||||||
|
ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { getBankAccounts, deleteBankAccount, deleteBankAccounts } from './actions';
|
import { getBankAccounts } from './actions';
|
||||||
|
|
||||||
// ===== 계좌번호 마스킹 함수 =====
|
// ===== 계좌번호 마스킹 =====
|
||||||
const maskAccountNumber = (accountNumber: string): string => {
|
const maskAccountNumber = (accountNumber: string): string => {
|
||||||
if (accountNumber.length <= 8) return accountNumber;
|
if (!accountNumber || accountNumber.length <= 8) return accountNumber || '';
|
||||||
const parts = accountNumber.split('-');
|
const parts = accountNumber.split('-');
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
// 1234-****-****-1234 형태
|
|
||||||
return parts.map((part, idx) => {
|
return parts.map((part, idx) => {
|
||||||
if (idx === 0 || idx === parts.length - 1) return part;
|
if (idx === 0 || idx === parts.length - 1) return part;
|
||||||
return '****';
|
return '****';
|
||||||
}).join('-');
|
}).join('-');
|
||||||
}
|
}
|
||||||
// 단순 형태: 앞 4자리-****-뒤 4자리
|
|
||||||
const first = accountNumber.slice(0, 4);
|
const first = accountNumber.slice(0, 4);
|
||||||
const last = accountNumber.slice(-4);
|
const last = accountNumber.slice(-4);
|
||||||
return `${first}-****-****-${last}`;
|
return `${first}-****-${last}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AccountManagement() {
|
export function AccountManagement() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// ===== 상태 관리 =====
|
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
// 로딩 상태
|
// ===== 날짜 범위 상태 =====
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const today = new Date();
|
||||||
|
const [startDate, setStartDate] = useState(() => format(startOfMonth(today), 'yyyy-MM-dd'));
|
||||||
|
const [endDate, setEndDate] = useState(() => format(endOfMonth(today), 'yyyy-MM-dd'));
|
||||||
|
|
||||||
// 삭제 다이얼로그
|
// ===== 필터 상태 =====
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const [institutionFilter, setInstitutionFilter] = useState<string>('all');
|
||||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ===== 액션 핸들러 =====
|
// ===== 핸들러 =====
|
||||||
const handleRowClick = useCallback((item: Account) => {
|
const handleRowClick = useCallback((item: Account) => {
|
||||||
router.push(`/ko/settings/accounts/${item.id}?mode=view`);
|
router.push(`/ko/settings/accounts/${item.id}?mode=view`);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleEdit = useCallback((item: Account) => {
|
|
||||||
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleDeleteClick = useCallback((id: string) => {
|
|
||||||
setDeleteTargetId(id);
|
|
||||||
setShowDeleteDialog(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
|
||||||
if (!deleteTargetId) return;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const result = await deleteBankAccount(Number(deleteTargetId));
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('계좌가 삭제되었습니다.');
|
|
||||||
// 페이지 새로고침으로 데이터 갱신
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('서버 오류가 발생했습니다.');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
|
||||||
}, [deleteTargetId]);
|
|
||||||
|
|
||||||
const handleBulkDelete = useCallback((selectedIds: string[]) => {
|
|
||||||
if (selectedIds.length > 0) {
|
|
||||||
setBulkDeleteIds(selectedIds);
|
|
||||||
setShowBulkDeleteDialog(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirmBulkDelete = useCallback(async () => {
|
|
||||||
const ids = bulkDeleteIds.map(id => Number(id));
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const result = await deleteBankAccounts(ids);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`${result.deletedCount}개의 계좌가 삭제되었습니다.`);
|
|
||||||
if (result.error) {
|
|
||||||
toast.warning(result.error);
|
|
||||||
}
|
|
||||||
// 페이지 새로고침으로 데이터 갱신
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('서버 오류가 발생했습니다.');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
setShowBulkDeleteDialog(false);
|
|
||||||
setBulkDeleteIds([]);
|
|
||||||
}
|
|
||||||
}, [bulkDeleteIds]);
|
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
router.push('/ko/settings/accounts?mode=new');
|
router.push('/ko/settings/accounts?mode=new');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// ===== 금융기관 필터 옵션 =====
|
||||||
|
const institutionFilterOptions = useMemo(() => [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||||
|
], []);
|
||||||
|
|
||||||
// ===== UniversalListPage Config =====
|
// ===== UniversalListPage Config =====
|
||||||
const config: UniversalListConfig<Account> = useMemo(
|
const config: UniversalListConfig<Account> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
// 페이지 기본 정보
|
title: '계좌 관리',
|
||||||
title: '계좌관리',
|
|
||||||
description: '계좌 목록을 관리합니다',
|
description: '계좌 목록을 관리합니다',
|
||||||
icon: Landmark,
|
icon: Landmark,
|
||||||
basePath: '/settings/accounts',
|
basePath: '/settings/accounts',
|
||||||
|
|
||||||
// ID 추출
|
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
getItemId: (item: Account) => String(item.id),
|
getItemId: (item: Account) => String(item.id),
|
||||||
|
|
||||||
|
// 체크박스 없음
|
||||||
|
showCheckbox: false,
|
||||||
|
|
||||||
|
// 날짜 범위 선택기 + 프리셋 버튼
|
||||||
|
dateRangeSelector: {
|
||||||
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
|
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||||
|
presetLabels: {
|
||||||
|
thisMonth: '이번달',
|
||||||
|
lastMonth: '지난달',
|
||||||
|
},
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange: setStartDate,
|
||||||
|
onEndDateChange: setEndDate,
|
||||||
|
},
|
||||||
|
|
||||||
// API 액션
|
// API 액션
|
||||||
actions: {
|
actions: {
|
||||||
getList: async (params?: ListParams) => {
|
getList: async (params?: ListParams) => {
|
||||||
try {
|
try {
|
||||||
const result = await getBankAccounts();
|
const result = await getBankAccounts();
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// 클라이언트 사이드 검색 필터링
|
|
||||||
let filteredData = result.data;
|
let filteredData = result.data;
|
||||||
|
|
||||||
|
// 구분 필터
|
||||||
|
if (categoryFilter && categoryFilter !== 'all') {
|
||||||
|
filteredData = filteredData.filter(item => item.category === categoryFilter);
|
||||||
|
}
|
||||||
|
// 금융기관 필터
|
||||||
|
if (institutionFilter && institutionFilter !== 'all') {
|
||||||
|
filteredData = filteredData.filter(item => item.bankCode === institutionFilter);
|
||||||
|
}
|
||||||
|
// 검색 필터
|
||||||
if (params?.search) {
|
if (params?.search) {
|
||||||
const search = params.search.toLowerCase();
|
const s = params.search.toLowerCase();
|
||||||
filteredData = result.data.filter(item =>
|
filteredData = filteredData.filter(item =>
|
||||||
item.accountName.toLowerCase().includes(search) ||
|
item.accountName?.toLowerCase().includes(s) ||
|
||||||
item.accountNumber.includes(search) ||
|
item.accountNumber?.includes(s) ||
|
||||||
item.accountHolder.toLowerCase().includes(search) ||
|
item.accountHolder?.toLowerCase().includes(s) ||
|
||||||
BANK_LABELS[item.bankCode]?.toLowerCase().includes(search)
|
BANK_LABELS[item.bankCode]?.toLowerCase().includes(s)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,23 +165,67 @@ export function AccountManagement() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 통계카드
|
||||||
|
computeStats: (data: Account[], totalCount: number): StatCard[] => {
|
||||||
|
// 전체 데이터 기준 (필터 무관하게)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '전체계좌',
|
||||||
|
value: totalCount,
|
||||||
|
icon: Landmark,
|
||||||
|
iconColor: 'text-blue-500',
|
||||||
|
onClick: () => setCategoryFilter('all'),
|
||||||
|
isActive: categoryFilter === 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '은행계좌',
|
||||||
|
value: data.filter(a => a.category === 'bank_account').length,
|
||||||
|
icon: Building2,
|
||||||
|
iconColor: 'text-green-500',
|
||||||
|
onClick: () => setCategoryFilter('bank_account'),
|
||||||
|
isActive: categoryFilter === 'bank_account',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '대출계좌',
|
||||||
|
value: data.filter(a => a.category === 'loan_account').length,
|
||||||
|
icon: CreditCard,
|
||||||
|
iconColor: 'text-orange-500',
|
||||||
|
onClick: () => setCategoryFilter('loan_account'),
|
||||||
|
isActive: categoryFilter === 'loan_account',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '증권계좌',
|
||||||
|
value: data.filter(a => a.category === 'securities_account').length,
|
||||||
|
icon: TrendingUp,
|
||||||
|
iconColor: 'text-purple-500',
|
||||||
|
onClick: () => setCategoryFilter('securities_account'),
|
||||||
|
isActive: categoryFilter === 'securities_account',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '보험계좌',
|
||||||
|
value: data.filter(a => a.category === 'insurance_account').length,
|
||||||
|
icon: Shield,
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
onClick: () => setCategoryFilter('insurance_account'),
|
||||||
|
isActive: categoryFilter === 'insurance_account',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
// 테이블 컬럼
|
// 테이블 컬럼
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||||
{ key: 'bank', label: '은행', className: 'min-w-[100px]' },
|
{ key: 'category', label: '구분', className: 'min-w-[80px]' },
|
||||||
|
{ key: 'accountType', label: '유형', className: 'min-w-[80px]' },
|
||||||
|
{ key: 'institution', label: '금융기관', className: 'min-w-[100px]' },
|
||||||
{ key: 'accountNumber', label: '계좌번호', className: 'min-w-[160px]' },
|
{ key: 'accountNumber', label: '계좌번호', className: 'min-w-[160px]' },
|
||||||
{ key: 'accountName', label: '계좌명', className: 'min-w-[120px]' },
|
{ key: 'accountName', label: '계좌명', className: 'min-w-[120px]' },
|
||||||
{ key: 'accountHolder', label: '예금주', className: 'min-w-[80px]' },
|
{ key: 'status', label: '상태', className: 'min-w-[70px]' },
|
||||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
|
||||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 클라이언트 사이드 필터링
|
|
||||||
clientSideFiltering: true,
|
clientSideFiltering: true,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
|
searchPlaceholder: '금융기관, 계좌번호, 계좌명 검색...',
|
||||||
// 검색
|
|
||||||
searchPlaceholder: '은행명, 계좌번호, 계좌명, 예금주 검색...',
|
|
||||||
searchFilter: (item: Account, search: string) => {
|
searchFilter: (item: Account, search: string) => {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -214,21 +237,47 @@ export function AccountManagement() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 헤더 액션
|
// 헤더 액션 - 수기 계좌 등록 버튼
|
||||||
headerActions: () => (
|
headerActions: () => (
|
||||||
<Button className="ml-auto" onClick={handleCreate}>
|
<Button
|
||||||
|
className="ml-auto bg-orange-500 hover:bg-orange-600 text-white"
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
계좌 등록
|
수기 계좌 등록
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
// 테이블 카드 내부 필터 (구분, 금융기관 Select) - "총 N건" 옆에 배치
|
||||||
onBulkDelete: handleBulkDelete,
|
tableHeaderActions: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
|
<SelectValue placeholder="구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={institutionFilter} onValueChange={setInstitutionFilter}>
|
||||||
|
<SelectTrigger className="w-[150px] h-9">
|
||||||
|
<SelectValue placeholder="금융기관" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{institutionFilterOptions.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
// 테이블 행 렌더링
|
// 테이블 행 렌더링
|
||||||
renderTableRow: (
|
renderTableRow: (
|
||||||
item: Account,
|
item: Account,
|
||||||
index: number,
|
_index: number,
|
||||||
globalIndex: number,
|
globalIndex: number,
|
||||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||||
) => {
|
) => {
|
||||||
@@ -238,49 +287,40 @@ export function AccountManagement() {
|
|||||||
className="hover:bg-muted/50 cursor-pointer"
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
>
|
>
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
|
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
|
||||||
<TableCell>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
|
<TableCell>
|
||||||
<TableCell className="font-mono">{maskAccountNumber(item.accountNumber)}</TableCell>
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{ACCOUNT_CATEGORY_LABELS[item.category] || item.category}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{ACCOUNT_TYPE_LABELS[item.accountType] || item.accountType || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
|
||||||
|
item.isManual ? 'bg-orange-400' : 'bg-blue-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{BANK_LABELS[item.bankCode] || item.bankCode}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{maskAccountNumber(item.accountNumber)}</TableCell>
|
||||||
<TableCell>{item.accountName}</TableCell>
|
<TableCell>{item.accountName}</TableCell>
|
||||||
<TableCell>{item.accountHolder}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
|
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
|
||||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{handlers.isSelected && (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(item)}
|
|
||||||
title="수정"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteClick(String(item.id))}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 모바일 카드 렌더링
|
// 모바일 카드
|
||||||
renderMobileCard: (
|
renderMobileCard: (
|
||||||
item: Account,
|
item: Account,
|
||||||
index: number,
|
_index: number,
|
||||||
globalIndex: number,
|
globalIndex: number,
|
||||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||||
) => {
|
) => {
|
||||||
@@ -292,7 +332,7 @@ export function AccountManagement() {
|
|||||||
headerBadges={
|
headerBadges={
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
#{globalIndex}
|
{ACCOUNT_CATEGORY_LABELS[item.category]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{BANK_LABELS[item.bankCode] || item.bankCode}
|
{BANK_LABELS[item.bankCode] || item.bankCode}
|
||||||
@@ -304,85 +344,39 @@ export function AccountManagement() {
|
|||||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
isSelected={handlers.isSelected}
|
isSelected={false}
|
||||||
onToggleSelection={handlers.onToggle}
|
onToggleSelection={() => {}}
|
||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
infoGrid={
|
infoGrid={
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<InfoField label="유형" value={ACCOUNT_TYPE_LABELS[item.accountType] || '-'} />
|
||||||
<InfoField label="계좌번호" value={maskAccountNumber(item.accountNumber)} />
|
<InfoField label="계좌번호" value={maskAccountNumber(item.accountNumber)} />
|
||||||
<InfoField label="예금주" value={item.accountHolder} />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
|
||||||
handlers.isSelected ? (
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
className="flex-1 min-w-[100px] h-11"
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteClick(String(item.id)); }}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 테이블 카드 내부 하단 - 범례 (수기/연동)
|
||||||
|
tableFooter: (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="border-0">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-orange-400" />
|
||||||
|
수기 계좌
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-blue-400" />
|
||||||
|
연동 계좌
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
[handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleBulkDelete]
|
[handleCreate, handleRowClick, categoryFilter, institutionFilter, institutionFilterOptions, startDate, endDate]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <UniversalListPage config={config} />;
|
||||||
<>
|
}
|
||||||
<UniversalListPage config={config} />
|
|
||||||
|
|
||||||
{/* 단일 삭제 확인 다이얼로그 */}
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={showDeleteDialog}
|
|
||||||
onOpenChange={setShowDeleteDialog}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
title="계좌 삭제"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
계좌를 정말 삭제하시겠습니까?
|
|
||||||
<br />
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
삭제된 계좌의 과거 사용 내역은 보존됩니다.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 다중 삭제 확인 다이얼로그 */}
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={showBulkDeleteDialog}
|
|
||||||
onOpenChange={setShowBulkDeleteDialog}
|
|
||||||
onConfirm={handleConfirmBulkDelete}
|
|
||||||
title="계좌 삭제"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
선택하신 <strong>{bulkDeleteIds.length}개</strong>의 계좌를 정말 삭제하시겠습니까?
|
|
||||||
<br />
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
삭제된 계좌의 과거 사용 내역은 보존됩니다.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,65 @@ export const ACCOUNT_STATUS_COLORS: Record<AccountStatus, string> = {
|
|||||||
inactive: 'bg-gray-100 text-gray-500',
|
inactive: 'bg-gray-100 text-gray-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== 은행 목록 =====
|
// ===== 계좌 구분 =====
|
||||||
|
export type AccountCategory = 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_LABELS: Record<AccountCategory, string> = {
|
||||||
|
bank_account: '은행계좌',
|
||||||
|
loan_account: '대출계좌',
|
||||||
|
securities_account: '증권계좌',
|
||||||
|
insurance_account: '보험계좌',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'bank_account', label: '은행계좌' },
|
||||||
|
{ value: 'loan_account', label: '대출계좌' },
|
||||||
|
{ value: 'securities_account', label: '증권계좌' },
|
||||||
|
{ value: 'insurance_account', label: '보험계좌' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ACCOUNT_CATEGORY_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...ACCOUNT_CATEGORY_OPTIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 계좌 유형 (구분별) =====
|
||||||
|
export type BankAccountType = 'savings' | 'fixed_deposit' | 'installment_savings' | 'foreign_currency' | 'other';
|
||||||
|
export type LoanAccountType = 'facility_fund' | 'operating_fund' | 'other';
|
||||||
|
export type SecuritiesAccountType = 'direct_investment' | 'fund' | 'trust' | 'other';
|
||||||
|
export type InsuranceAccountType = 'group_insurance' | 'fire_insurance' | 'ceo_insurance';
|
||||||
|
|
||||||
|
export const ACCOUNT_TYPE_OPTIONS_BY_CATEGORY: Record<AccountCategory, { value: string; label: string }[]> = {
|
||||||
|
bank_account: [
|
||||||
|
{ value: 'savings', label: '보통예금' },
|
||||||
|
{ value: 'fixed_deposit', label: '정기예금' },
|
||||||
|
{ value: 'installment_savings', label: '적금' },
|
||||||
|
{ value: 'foreign_currency', label: '외화예금' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
],
|
||||||
|
loan_account: [
|
||||||
|
{ value: 'facility_fund', label: '시설자금' },
|
||||||
|
{ value: 'operating_fund', label: '운전자금' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
],
|
||||||
|
securities_account: [
|
||||||
|
{ value: 'direct_investment', label: '직접투자' },
|
||||||
|
{ value: 'fund', label: '펀드' },
|
||||||
|
{ value: 'trust', label: '신탁' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
],
|
||||||
|
insurance_account: [
|
||||||
|
{ value: 'group_insurance', label: '단체보험' },
|
||||||
|
{ value: 'fire_insurance', label: '화재보험' },
|
||||||
|
{ value: 'ceo_insurance', label: 'CEO보험' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACCOUNT_TYPE_LABELS: Record<string, string> = Object.values(ACCOUNT_TYPE_OPTIONS_BY_CATEGORY)
|
||||||
|
.flat()
|
||||||
|
.reduce((acc, opt) => ({ ...acc, [opt.value]: opt.label }), {} as Record<string, string>);
|
||||||
|
|
||||||
|
// ===== 금융기관 목록 (은행 + 증권 + 보험) =====
|
||||||
export const BANK_OPTIONS = [
|
export const BANK_OPTIONS = [
|
||||||
{ value: 'shinhan', label: '신한은행' },
|
{ value: 'shinhan', label: '신한은행' },
|
||||||
{ value: 'kb', label: 'KB국민은행' },
|
{ value: 'kb', label: 'KB국민은행' },
|
||||||
@@ -41,7 +99,44 @@ export const BANK_OPTIONS = [
|
|||||||
{ value: 'shinhyup', label: '신협' },
|
{ value: 'shinhyup', label: '신협' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BANK_LABELS: Record<string, string> = BANK_OPTIONS.reduce(
|
export const SECURITIES_OPTIONS = [
|
||||||
|
{ value: 'mirae', label: '미래에셋증권' },
|
||||||
|
{ value: 'samsung', label: '삼성증권' },
|
||||||
|
{ value: 'kb_securities', label: 'KB증권' },
|
||||||
|
{ value: 'nh_securities', label: 'NH투자증권' },
|
||||||
|
{ value: 'hana_securities', label: '하나증권' },
|
||||||
|
{ value: 'shinhan_securities', label: '신한투자증권' },
|
||||||
|
{ value: 'korea_invest', label: '한국투자증권' },
|
||||||
|
{ value: 'kiwoom', label: '키움증권' },
|
||||||
|
{ value: 'daishin', label: '대신증권' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INSURANCE_OPTIONS = [
|
||||||
|
{ value: 'samsung_life', label: '삼성생명' },
|
||||||
|
{ value: 'samsung_fire', label: '삼성화재' },
|
||||||
|
{ value: 'hanwha_life', label: '한화생명' },
|
||||||
|
{ value: 'kyobo', label: '교보생명' },
|
||||||
|
{ value: 'db_insurance', label: 'DB손해보험' },
|
||||||
|
{ value: 'hyundai_marine', label: '현대해상' },
|
||||||
|
{ value: 'kb_insurance', label: 'KB손해보험' },
|
||||||
|
{ value: 'meritz_fire', label: '메리츠화재' },
|
||||||
|
{ value: 'nh_life', label: 'NH농협생명' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY: Record<AccountCategory, { value: string; label: string }[]> = {
|
||||||
|
bank_account: BANK_OPTIONS,
|
||||||
|
loan_account: BANK_OPTIONS,
|
||||||
|
securities_account: SECURITIES_OPTIONS,
|
||||||
|
insurance_account: INSURANCE_OPTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_FINANCIAL_INSTITUTION_OPTIONS = [
|
||||||
|
...BANK_OPTIONS,
|
||||||
|
...SECURITIES_OPTIONS,
|
||||||
|
...INSURANCE_OPTIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BANK_LABELS: Record<string, string> = ALL_FINANCIAL_INSTITUTION_OPTIONS.reduce(
|
||||||
(acc, opt) => ({ ...acc, [opt.value]: opt.label }),
|
(acc, opt) => ({ ...acc, [opt.value]: opt.label }),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
@@ -59,25 +154,123 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|||||||
// ===== 계좌 인터페이스 =====
|
// ===== 계좌 인터페이스 =====
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: number;
|
id: number;
|
||||||
bankCode: string; // 은행 코드
|
bankCode: string;
|
||||||
bankName: string; // 은행명
|
bankName: string;
|
||||||
accountNumber: string; // 계좌번호
|
accountNumber: string;
|
||||||
accountName: string; // 계좌명
|
accountName: string;
|
||||||
accountHolder: string; // 예금주
|
accountHolder: string;
|
||||||
status: AccountStatus; // 상태 (사용/정지)
|
status: AccountStatus;
|
||||||
isPrimary: boolean; // 대표 계좌 여부
|
isPrimary: boolean;
|
||||||
assignedUserId?: number; // 담당자 ID
|
assignedUserId?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
|
// 신규 공통
|
||||||
|
category: AccountCategory;
|
||||||
|
accountType: string;
|
||||||
|
isManual: boolean;
|
||||||
|
|
||||||
|
// 은행계좌 정보
|
||||||
|
contractAmount?: number;
|
||||||
|
interestRate?: number;
|
||||||
|
startDate?: string;
|
||||||
|
maturityDate?: string;
|
||||||
|
carryoverBalance?: number;
|
||||||
|
|
||||||
|
// 대출계좌 정보
|
||||||
|
loanAmount?: number;
|
||||||
|
loanBalance?: number;
|
||||||
|
interestPaymentCycle?: string;
|
||||||
|
repaymentMethod?: string;
|
||||||
|
gracePeriod?: string;
|
||||||
|
monthlyRepayment?: number;
|
||||||
|
collateral?: string;
|
||||||
|
|
||||||
|
// 증권계좌 정보
|
||||||
|
investmentAmount?: number;
|
||||||
|
returnRate?: number;
|
||||||
|
evaluationAmount?: number;
|
||||||
|
|
||||||
|
// 보험계좌 공통
|
||||||
|
surrenderValue?: number;
|
||||||
|
policyNumber?: string;
|
||||||
|
paymentCycle?: string;
|
||||||
|
premiumPerCycle?: number;
|
||||||
|
// 단체보험
|
||||||
|
premiumPerPerson?: number;
|
||||||
|
enrolledCount?: number;
|
||||||
|
// 화재보험
|
||||||
|
insuredProperty?: string;
|
||||||
|
propertyAddress?: string;
|
||||||
|
// CEO보험
|
||||||
|
beneficiary?: string;
|
||||||
|
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 계좌 폼 데이터 =====
|
// ===== 계좌 폼 데이터 =====
|
||||||
export interface AccountFormData {
|
export interface AccountFormData {
|
||||||
|
// 공통
|
||||||
|
category: AccountCategory;
|
||||||
|
accountType: string;
|
||||||
bankCode: string;
|
bankCode: string;
|
||||||
bankName: string; // 은행명 (bank_code에서 매핑)
|
bankName: string;
|
||||||
accountNumber: string;
|
accountNumber: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
accountPassword: string; // 빠른 조회 서비스용 (클라이언트 전용, API 미전송)
|
|
||||||
status: AccountStatus;
|
status: AccountStatus;
|
||||||
|
|
||||||
|
// 은행계좌
|
||||||
|
contractAmount?: number;
|
||||||
|
interestRate?: number;
|
||||||
|
startDate?: string;
|
||||||
|
maturityDate?: string;
|
||||||
|
carryoverBalance?: number;
|
||||||
|
|
||||||
|
// 대출계좌
|
||||||
|
loanAmount?: number;
|
||||||
|
loanBalance?: number;
|
||||||
|
interestPaymentCycle?: string;
|
||||||
|
repaymentMethod?: string;
|
||||||
|
gracePeriod?: string;
|
||||||
|
monthlyRepayment?: number;
|
||||||
|
collateral?: string;
|
||||||
|
|
||||||
|
// 증권계좌
|
||||||
|
investmentAmount?: number;
|
||||||
|
returnRate?: number;
|
||||||
|
evaluationAmount?: number;
|
||||||
|
|
||||||
|
// 보험계좌 공통
|
||||||
|
surrenderValue?: number;
|
||||||
|
policyNumber?: string;
|
||||||
|
paymentCycle?: string;
|
||||||
|
premiumPerCycle?: number;
|
||||||
|
// 단체보험
|
||||||
|
premiumPerPerson?: number;
|
||||||
|
enrolledCount?: number;
|
||||||
|
// 화재보험
|
||||||
|
insuredProperty?: string;
|
||||||
|
propertyAddress?: string;
|
||||||
|
// CEO보험
|
||||||
|
beneficiary?: string;
|
||||||
|
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 통계 요약 =====
|
||||||
|
export interface AccountSummary {
|
||||||
|
total: number;
|
||||||
|
bankAccount: number;
|
||||||
|
loanAccount: number;
|
||||||
|
securitiesAccount: number;
|
||||||
|
insuranceAccount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 예금주/계약자/피보험자 라벨 =====
|
||||||
|
export const HOLDER_LABEL_BY_CATEGORY: Record<AccountCategory, string> = {
|
||||||
|
bank_account: '예금주',
|
||||||
|
loan_account: '차입자',
|
||||||
|
securities_account: '명의자',
|
||||||
|
insurance_account: '피보험자',
|
||||||
|
};
|
||||||
|
|||||||
132
src/components/settings/BarobillIntegration/BankServiceModal.tsx
Normal file
132
src/components/settings/BarobillIntegration/BankServiceModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { BANK_OPTIONS, ACCOUNT_TYPE_OPTIONS } from './types';
|
||||||
|
import type { BankServiceFormData } from './types';
|
||||||
|
import { getBankServiceUrl } from './actions';
|
||||||
|
|
||||||
|
interface BankServiceModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BankServiceModal({ open, onOpenChange }: BankServiceModalProps) {
|
||||||
|
const [formData, setFormData] = useState<BankServiceFormData>({
|
||||||
|
bankCode: BANK_OPTIONS[0].value,
|
||||||
|
accountType: ACCOUNT_TYPE_OPTIONS[0].value,
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
if (isOpen) {
|
||||||
|
setFormData({
|
||||||
|
bankCode: BANK_OPTIONS[0].value,
|
||||||
|
accountType: ACCOUNT_TYPE_OPTIONS[0].value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await getBankServiceUrl(formData);
|
||||||
|
if (result.success && result.data?.url) {
|
||||||
|
window.open(result.data.url, '_blank');
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '서비스 URL 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서비스 URL 조회 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, onOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[450px]" aria-describedby={undefined}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>은행 빠른조회 서비스 등록</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 은행 Select */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
은행 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.bankCode}
|
||||||
|
onValueChange={(v) => setFormData(prev => ({ ...prev, bankCode: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BANK_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분 Select */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
구분 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.accountType}
|
||||||
|
onValueChange={(v) => setFormData(prev => ({ ...prev, accountType: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACCOUNT_TYPE_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'바로가기'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/settings/BarobillIntegration/LoginModal.tsx
Normal file
111
src/components/settings/BarobillIntegration/LoginModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { BarobillLoginFormData } from './types';
|
||||||
|
import { registerBarobillLogin } from './actions';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: BarobillLoginFormData = {
|
||||||
|
barobillId: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginModal({ open, onOpenChange, onSuccess }: LoginModalProps) {
|
||||||
|
const [formData, setFormData] = useState<BarobillLoginFormData>(initialFormData);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
if (isOpen) setFormData(initialFormData);
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((key: keyof BarobillLoginFormData, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.barobillId) {
|
||||||
|
toast.error('바로빌 아이디를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.password) {
|
||||||
|
toast.error('비밀번호를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await registerBarobillLogin(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('바로빌 로그인 정보가 등록되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('등록 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[450px]" aria-describedby={undefined}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>바로빌 로그인 정보 등록</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<FormField
|
||||||
|
label="바로빌 아이디"
|
||||||
|
required
|
||||||
|
value={formData.barobillId}
|
||||||
|
onChange={(v) => handleChange('barobillId', v)}
|
||||||
|
placeholder="Barobill_id"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="password"
|
||||||
|
label="비밀번호"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(v) => handleChange('password', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
등록 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'등록하기'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/components/settings/BarobillIntegration/SignupModal.tsx
Normal file
210
src/components/settings/BarobillIntegration/SignupModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { BarobillSignupFormData } from './types';
|
||||||
|
import { registerBarobillSignup } from './actions';
|
||||||
|
|
||||||
|
interface SignupModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: BarobillSignupFormData = {
|
||||||
|
businessNumber: '',
|
||||||
|
companyName: '',
|
||||||
|
ceoName: '',
|
||||||
|
businessType: '',
|
||||||
|
businessCategory: '',
|
||||||
|
address: '',
|
||||||
|
barobillId: '',
|
||||||
|
password: '',
|
||||||
|
managerName: '',
|
||||||
|
managerPhone: '',
|
||||||
|
managerEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignupModal({ open, onOpenChange, onSuccess }: SignupModalProps) {
|
||||||
|
const [formData, setFormData] = useState<BarobillSignupFormData>(initialFormData);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
if (isOpen) setFormData(initialFormData);
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((key: keyof BarobillSignupFormData, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.businessNumber) {
|
||||||
|
toast.error('사업자등록번호를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.companyName) {
|
||||||
|
toast.error('상호명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.ceoName) {
|
||||||
|
toast.error('대표자명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.barobillId) {
|
||||||
|
toast.error('바로빌 아이디를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.password) {
|
||||||
|
toast.error('비밀번호를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await registerBarobillSignup(formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('바로빌 회원가입 정보가 등록되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('등록 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, onOpenChange, onSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>바로빌 회원가입 정보 등록</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 사업자등록번호 + 상호명 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
type="businessNumber"
|
||||||
|
label="사업자등록번호"
|
||||||
|
required
|
||||||
|
value={formData.businessNumber}
|
||||||
|
onChange={(v) => handleChange('businessNumber', v)}
|
||||||
|
placeholder="123-12-12345"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="상호명"
|
||||||
|
required
|
||||||
|
value={formData.companyName}
|
||||||
|
onChange={(v) => handleChange('companyName', v)}
|
||||||
|
placeholder="(주)회사명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대표자명 + 업태 + 업종 */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="대표자명"
|
||||||
|
required
|
||||||
|
value={formData.ceoName}
|
||||||
|
onChange={(v) => handleChange('ceoName', v)}
|
||||||
|
placeholder="홍길동"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="업태"
|
||||||
|
value={formData.businessType}
|
||||||
|
onChange={(v) => handleChange('businessType', v)}
|
||||||
|
placeholder="업태"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="업종"
|
||||||
|
value={formData.businessCategory}
|
||||||
|
onChange={(v) => handleChange('businessCategory', v)}
|
||||||
|
placeholder="업종"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 주소 */}
|
||||||
|
<FormField
|
||||||
|
label="주소"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(v) => handleChange('address', v)}
|
||||||
|
placeholder="서울특별시 강남구"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 바로빌 아이디 + 비밀번호 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="바로빌 아이디"
|
||||||
|
required
|
||||||
|
value={formData.barobillId}
|
||||||
|
onChange={(v) => handleChange('barobillId', v)}
|
||||||
|
placeholder="Barobill_id"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="password"
|
||||||
|
label="비밀번호"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(v) => handleChange('password', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 담당자명 + 담당자 연락처 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="담당자명"
|
||||||
|
value={formData.managerName}
|
||||||
|
onChange={(v) => handleChange('managerName', v)}
|
||||||
|
placeholder="홍길동"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="phone"
|
||||||
|
label="담당자 연락처"
|
||||||
|
value={formData.managerPhone}
|
||||||
|
onChange={(v) => handleChange('managerPhone', v)}
|
||||||
|
placeholder="010-1234-1234"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 담당자 이메일 */}
|
||||||
|
<FormField
|
||||||
|
label="담당자 이메일"
|
||||||
|
value={formData.managerEmail}
|
||||||
|
onChange={(v) => handleChange('managerEmail', v)}
|
||||||
|
placeholder="manager@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
등록 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'등록하기'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/settings/BarobillIntegration/actions.ts
Normal file
102
src/components/settings/BarobillIntegration/actions.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
import type {
|
||||||
|
BarobillLoginFormData,
|
||||||
|
BarobillSignupFormData,
|
||||||
|
BankServiceFormData,
|
||||||
|
IntegrationStatus,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== 바로빌 로그인 정보 등록 =====
|
||||||
|
export async function registerBarobillLogin(
|
||||||
|
data: BarobillLoginFormData
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/login'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
barobill_id: data.barobillId,
|
||||||
|
password: data.password,
|
||||||
|
},
|
||||||
|
errorMessage: '바로빌 로그인 등록에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 바로빌 회원가입 정보 등록 =====
|
||||||
|
export async function registerBarobillSignup(
|
||||||
|
data: BarobillSignupFormData
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/signup'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
business_number: data.businessNumber,
|
||||||
|
company_name: data.companyName,
|
||||||
|
ceo_name: data.ceoName,
|
||||||
|
business_type: data.businessType || undefined,
|
||||||
|
business_category: data.businessCategory || undefined,
|
||||||
|
address: data.address || undefined,
|
||||||
|
barobill_id: data.barobillId,
|
||||||
|
password: data.password,
|
||||||
|
manager_name: data.managerName || undefined,
|
||||||
|
manager_phone: data.managerPhone || undefined,
|
||||||
|
manager_email: data.managerEmail || undefined,
|
||||||
|
},
|
||||||
|
errorMessage: '바로빌 회원가입 등록에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 은행 빠른조회 서비스 URL 조회 =====
|
||||||
|
export async function getBankServiceUrl(
|
||||||
|
data: BankServiceFormData
|
||||||
|
): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/bank-service-url', {
|
||||||
|
bank_code: data.bankCode,
|
||||||
|
account_type: data.accountType,
|
||||||
|
}),
|
||||||
|
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||||
|
errorMessage: '은행 빠른조회 서비스 URL 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 연동 현황 조회 =====
|
||||||
|
export async function getIntegrationStatus(): Promise<ActionResult<IntegrationStatus>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/status'),
|
||||||
|
transform: (data: { bank_service_count?: number; account_link_count?: number }) => ({
|
||||||
|
bankServiceCount: data.bank_service_count ?? 0,
|
||||||
|
accountLinkCount: data.account_link_count ?? 0,
|
||||||
|
}),
|
||||||
|
errorMessage: '연동 현황 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계좌 연동 등록 URL 조회 =====
|
||||||
|
export async function getAccountLinkUrl(): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/account-link-url'),
|
||||||
|
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||||
|
errorMessage: '계좌 연동 페이지 URL 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카드 연동 등록 URL 조회 =====
|
||||||
|
export async function getCardLinkUrl(): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/card-link-url'),
|
||||||
|
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||||
|
errorMessage: '카드 연동 페이지 URL 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 공인인증서 등록 URL 조회 =====
|
||||||
|
export async function getCertificateUrl(): Promise<ActionResult<{ url: string }>> {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/barobill/certificate-url'),
|
||||||
|
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||||
|
errorMessage: '공인인증서 등록 페이지 URL 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
241
src/components/settings/BarobillIntegration/index.tsx
Normal file
241
src/components/settings/BarobillIntegration/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { Link2, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { LoginModal } from './LoginModal';
|
||||||
|
import { SignupModal } from './SignupModal';
|
||||||
|
import { BankServiceModal } from './BankServiceModal';
|
||||||
|
import {
|
||||||
|
getIntegrationStatus,
|
||||||
|
getAccountLinkUrl,
|
||||||
|
getCardLinkUrl,
|
||||||
|
getCertificateUrl,
|
||||||
|
} from './actions';
|
||||||
|
import type { IntegrationStatus } from './types';
|
||||||
|
|
||||||
|
export function BarobillIntegration() {
|
||||||
|
const [status, setStatus] = useState<IntegrationStatus | null>(null);
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const [signupOpen, setSignupOpen] = useState(false);
|
||||||
|
const [bankServiceOpen, setBankServiceOpen] = useState(false);
|
||||||
|
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getIntegrationStatus();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setStatus(result.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 상태 조회 실패 시 무시 (페이지 렌더링에 영향 없음)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
const handleExternalLink = useCallback(async (
|
||||||
|
type: 'account' | 'card' | 'certificate',
|
||||||
|
) => {
|
||||||
|
setLoadingAction(type);
|
||||||
|
try {
|
||||||
|
const actionMap = {
|
||||||
|
account: getAccountLinkUrl,
|
||||||
|
card: getCardLinkUrl,
|
||||||
|
certificate: getCertificateUrl,
|
||||||
|
};
|
||||||
|
const result = await actionMap[type]();
|
||||||
|
if (result.success && result.data?.url) {
|
||||||
|
window.open(result.data.url, '_blank');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '서비스 페이지 URL 조회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('서비스 페이지 URL 조회 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="바로빌 연동 관리"
|
||||||
|
description="바로빌 청구, 장부를 연동합니다."
|
||||||
|
icon={Link2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 바로빌 연동 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">바로빌 연동</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 바로빌 로그인 정보 등록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h3 className="font-semibold mb-2">바로빌 회원이신가요?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
바로빌 로그인 발급 정보 등록
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setLoginOpen(true)}
|
||||||
|
>
|
||||||
|
바로빌 로그인 정보 등록
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 바로빌 회원가입 등록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h3 className="font-semibold mb-2">바로빌 회원이 아니신가요?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
바로빌 회원가입 발급 등록 가능
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setSignupOpen(true)}
|
||||||
|
>
|
||||||
|
바로빌 회원가입 등록
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 계좌 연동 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">계좌 연동</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 은행 빠른조회 서비스 등록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold">은행 빠른조회 서비스 등록</h3>
|
||||||
|
{status && status.bankServiceCount > 0 && (
|
||||||
|
<Badge variant="secondary">{status.bankServiceCount}개</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||||
|
<li>— 전 은행 인터넷뱅킹가입 필수</li>
|
||||||
|
<li>— 빠른조회/공과서비스 가입</li>
|
||||||
|
<li>— 조회용 계좌 등록</li>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setBankServiceOpen(true)}
|
||||||
|
>
|
||||||
|
은행 빠른조회 서비스 등록
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 계좌 연동 등록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold">계좌 연동 등록</h3>
|
||||||
|
{status && status.accountLinkCount > 0 && (
|
||||||
|
<Badge variant="secondary">{status.accountLinkCount}개</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||||
|
<li>— 바로빌에 연동되어있는 계좌 등록</li>
|
||||||
|
<li>— 조회 가능 (실시간/주기적)</li>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleExternalLink('account')}
|
||||||
|
disabled={loadingAction === 'account'}
|
||||||
|
>
|
||||||
|
{loadingAction === 'account' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||||
|
) : '계좌 연동 등록'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 카드 연동 & 공인인증서 등록 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 카드 연동 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">카드 연동</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h3 className="font-semibold mb-2">카드 연동 등록</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||||
|
<li>— 바로빌에 연동 카드 정보 등록</li>
|
||||||
|
<li>— 카드사/비밀디카번/번호 입력</li>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleExternalLink('card')}
|
||||||
|
disabled={loadingAction === 'card'}
|
||||||
|
>
|
||||||
|
{loadingAction === 'card' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||||
|
) : '카드 연동 등록'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 공인인증서 등록 */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">공인인증서 등록</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h3 className="font-semibold mb-2">세금계산서용 공인인증서 등록</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||||
|
<li>— 팩스 세금계산서 연동 등록</li>
|
||||||
|
<li>— 범용 공인인증서만 등록 가능 (전자발급용으로도 가능)</li>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleExternalLink('certificate')}
|
||||||
|
disabled={loadingAction === 'certificate'}
|
||||||
|
>
|
||||||
|
{loadingAction === 'certificate' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||||
|
) : '공인인증서 등록'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 */}
|
||||||
|
<LoginModal
|
||||||
|
open={loginOpen}
|
||||||
|
onOpenChange={setLoginOpen}
|
||||||
|
onSuccess={loadStatus}
|
||||||
|
/>
|
||||||
|
<SignupModal
|
||||||
|
open={signupOpen}
|
||||||
|
onOpenChange={setSignupOpen}
|
||||||
|
onSuccess={loadStatus}
|
||||||
|
/>
|
||||||
|
<BankServiceModal
|
||||||
|
open={bankServiceOpen}
|
||||||
|
onOpenChange={setBankServiceOpen}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/settings/BarobillIntegration/types.ts
Normal file
57
src/components/settings/BarobillIntegration/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// ===== 바로빌 로그인 정보 등록 =====
|
||||||
|
export interface BarobillLoginFormData {
|
||||||
|
barobillId: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 바로빌 회원가입 정보 등록 =====
|
||||||
|
export interface BarobillSignupFormData {
|
||||||
|
businessNumber: string;
|
||||||
|
companyName: string;
|
||||||
|
ceoName: string;
|
||||||
|
businessType: string;
|
||||||
|
businessCategory: string;
|
||||||
|
address: string;
|
||||||
|
barobillId: string;
|
||||||
|
password: string;
|
||||||
|
managerName: string;
|
||||||
|
managerPhone: string;
|
||||||
|
managerEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 은행 빠른조회 서비스 등록 =====
|
||||||
|
export interface BankServiceFormData {
|
||||||
|
bankCode: string;
|
||||||
|
accountType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 은행 목록 옵션 =====
|
||||||
|
export const BANK_OPTIONS = [
|
||||||
|
{ value: 'kookmin', label: '국민은행' },
|
||||||
|
{ value: 'shinhan', label: '신한은행' },
|
||||||
|
{ value: 'woori', label: '우리은행' },
|
||||||
|
{ value: 'hana', label: '하나은행' },
|
||||||
|
{ value: 'nonghyup', label: '농협은행' },
|
||||||
|
{ value: 'ibk', label: '기업은행' },
|
||||||
|
{ value: 'kdb', label: '산업은행' },
|
||||||
|
{ value: 'sc', label: 'SC제일은행' },
|
||||||
|
{ value: 'citi', label: '씨티은행' },
|
||||||
|
{ value: 'daegu', label: '대구은행' },
|
||||||
|
{ value: 'busan', label: '부산은행' },
|
||||||
|
{ value: 'kwangju', label: '광주은행' },
|
||||||
|
{ value: 'jeju', label: '제주은행' },
|
||||||
|
{ value: 'jeonbuk', label: '전북은행' },
|
||||||
|
{ value: 'kyongnam', label: '경남은행' },
|
||||||
|
{ value: 'suhyup', label: '수협은행' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ACCOUNT_TYPE_OPTIONS = [
|
||||||
|
{ value: 'corporate', label: '기업' },
|
||||||
|
{ value: 'personal', label: '개인' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ===== 연동 현황 =====
|
||||||
|
export interface IntegrationStatus {
|
||||||
|
bankServiceCount: number;
|
||||||
|
accountLinkCount: number;
|
||||||
|
}
|
||||||
@@ -109,6 +109,12 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
|||||||
showPresets?: boolean;
|
showPresets?: boolean;
|
||||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||||
hideDateInputs?: boolean;
|
hideDateInputs?: boolean;
|
||||||
|
/** 표시할 프리셋 목록 */
|
||||||
|
presets?: import('@/components/molecules/DateRangeSelector').DatePreset[];
|
||||||
|
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||||
|
presetLabels?: Partial<Record<import('@/components/molecules/DateRangeSelector').DatePreset, string>>;
|
||||||
|
/** 프리셋 버튼 위치 */
|
||||||
|
presetsPosition?: 'inline' | 'below';
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
onStartDateChange?: (date: string) => void;
|
onStartDateChange?: (date: string) => void;
|
||||||
@@ -603,6 +609,9 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
onEndDateChange={dateRangeSelector.onEndDateChange || (() => {})}
|
onEndDateChange={dateRangeSelector.onEndDateChange || (() => {})}
|
||||||
hidePresets={dateRangeSelector.showPresets === false}
|
hidePresets={dateRangeSelector.showPresets === false}
|
||||||
hideDateInputs={dateRangeSelector.hideDateInputs}
|
hideDateInputs={dateRangeSelector.hideDateInputs}
|
||||||
|
presets={dateRangeSelector.presets}
|
||||||
|
presetLabels={dateRangeSelector.presetLabels}
|
||||||
|
presetsPosition={dateRangeSelector.presetsPosition}
|
||||||
extraActions={
|
extraActions={
|
||||||
<>
|
<>
|
||||||
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
||||||
@@ -944,7 +953,7 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
onClick={isSortable ? () => onSort(column.key) : undefined}
|
onClick={isSortable ? () => onSort(column.key) : undefined}
|
||||||
>
|
>
|
||||||
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
||||||
<div className={`flex items-center gap-1 ${isSortable ? 'group' : ''}`}>
|
<div className={`flex items-center gap-1 ${isSortable ? 'group' : ''} ${(column.className || '').includes('text-right') ? 'justify-end' : (column.className || '').includes('text-center') ? 'justify-center' : ''}`}>
|
||||||
<span>{column.label}</span>
|
<span>{column.label}</span>
|
||||||
{isSortable && (
|
{isSortable && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ export interface UniversalListConfig<T> {
|
|||||||
showPresets?: boolean;
|
showPresets?: boolean;
|
||||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||||
hideDateInputs?: boolean;
|
hideDateInputs?: boolean;
|
||||||
|
/** 표시할 프리셋 목록 */
|
||||||
|
presets?: import('@/components/molecules/DateRangeSelector').DatePreset[];
|
||||||
|
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||||
|
presetLabels?: Partial<Record<import('@/components/molecules/DateRangeSelector').DatePreset, string>>;
|
||||||
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||||
presetsPosition?: 'inline' | 'below';
|
presetsPosition?: 'inline' | 'below';
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ import {
|
|||||||
import { ScrollArea } from "./scroll-area";
|
import { ScrollArea } from "./scroll-area";
|
||||||
|
|
||||||
interface TimePickerProps {
|
interface TimePickerProps {
|
||||||
value?: string; // "HH:mm" format
|
/** "HH:mm" 또는 showSeconds 시 "HH:mm:ss" 형식 */
|
||||||
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 분 단위 간격 (기본값: 5) */
|
/** 분 단위 간격 (기본값: 5) */
|
||||||
minuteStep?: number;
|
minuteStep?: number;
|
||||||
|
/** 초 선택 표시 여부 (기본값: false) */
|
||||||
|
showSeconds?: boolean;
|
||||||
|
/** 초 단위 간격 (기본값: 5) */
|
||||||
|
secondStep?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimePicker({
|
function TimePicker({
|
||||||
@@ -28,14 +33,16 @@ function TimePicker({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
minuteStep = 5,
|
minuteStep = 5,
|
||||||
|
showSeconds = false,
|
||||||
|
secondStep = 5,
|
||||||
}: TimePickerProps) {
|
}: TimePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
// 현재 선택된 시/분 파싱
|
// 현재 선택된 시/분/초 파싱
|
||||||
const [selectedHour, selectedMinute] = React.useMemo(() => {
|
const [selectedHour, selectedMinute, selectedSecond] = React.useMemo(() => {
|
||||||
if (!value) return [null, null];
|
if (!value) return [null, null, null];
|
||||||
const [h, m] = value.split(":").map(Number);
|
const parts = value.split(":").map(Number);
|
||||||
return [h, m];
|
return [parts[0] ?? null, parts[1] ?? null, parts[2] ?? null];
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// 시간 배열 생성 (0-23)
|
// 시간 배열 생성 (0-23)
|
||||||
@@ -47,18 +54,35 @@ function TimePicker({
|
|||||||
(_, i) => i * minuteStep
|
(_, i) => i * minuteStep
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 초 배열 생성 (secondStep 간격)
|
||||||
|
const seconds = React.useMemo(
|
||||||
|
() => Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => i * secondStep),
|
||||||
|
[secondStep]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 시간 문자열 빌더
|
||||||
|
const buildTimeValue = React.useCallback(
|
||||||
|
(h: number, m: number, s?: number) => {
|
||||||
|
const base = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||||||
|
if (showSeconds) return `${base}:${(s ?? 0).toString().padStart(2, "0")}`;
|
||||||
|
return base;
|
||||||
|
},
|
||||||
|
[showSeconds]
|
||||||
|
);
|
||||||
|
|
||||||
// 시간 선택 핸들러
|
// 시간 선택 핸들러
|
||||||
const handleHourSelect = (hour: number) => {
|
const handleHourSelect = (hour: number) => {
|
||||||
const minute = selectedMinute ?? 0;
|
onChange?.(buildTimeValue(hour, selectedMinute ?? 0, selectedSecond ?? 0));
|
||||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
|
||||||
onChange?.(newValue);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 분 선택 핸들러
|
// 분 선택 핸들러
|
||||||
const handleMinuteSelect = (minute: number) => {
|
const handleMinuteSelect = (minute: number) => {
|
||||||
const hour = selectedHour ?? 0;
|
onChange?.(buildTimeValue(selectedHour ?? 0, minute, selectedSecond ?? 0));
|
||||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
};
|
||||||
onChange?.(newValue);
|
|
||||||
|
// 초 선택 핸들러
|
||||||
|
const handleSecondSelect = (second: number) => {
|
||||||
|
onChange?.(buildTimeValue(selectedHour ?? 0, selectedMinute ?? 0, second));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 표시할 시간 텍스트
|
// 표시할 시간 텍스트
|
||||||
@@ -67,6 +91,7 @@ function TimePicker({
|
|||||||
// 스크롤 영역 ref
|
// 스크롤 영역 ref
|
||||||
const hourScrollRef = React.useRef<HTMLDivElement>(null);
|
const hourScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
const minuteScrollRef = React.useRef<HTMLDivElement>(null);
|
const minuteScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const secondScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 팝오버 열릴 때 선택된 시간으로 스크롤
|
// 팝오버 열릴 때 선택된 시간으로 스크롤
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -84,9 +109,15 @@ function TimePicker({
|
|||||||
);
|
);
|
||||||
minuteElement?.scrollIntoView({ block: "center" });
|
minuteElement?.scrollIntoView({ block: "center" });
|
||||||
}
|
}
|
||||||
|
if (showSeconds && selectedSecond !== null && secondScrollRef.current) {
|
||||||
|
const secondElement = secondScrollRef.current.querySelector(
|
||||||
|
`[data-second="${selectedSecond}"]`
|
||||||
|
);
|
||||||
|
secondElement?.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}, [open, selectedHour, selectedMinute]);
|
}, [open, selectedHour, selectedMinute, selectedSecond, showSeconds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -171,6 +202,40 @@ function TimePicker({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 초 선택 (showSeconds일 때만) */}
|
||||||
|
{showSeconds && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center pt-5">
|
||||||
|
<span className="text-2xl font-bold text-muted-foreground">:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-muted-foreground text-center mb-1 font-semibold">
|
||||||
|
초
|
||||||
|
</span>
|
||||||
|
<ScrollArea className="h-[200px] w-[70px] rounded-md border">
|
||||||
|
<div className="p-1" ref={secondScrollRef}>
|
||||||
|
{seconds.map((second) => (
|
||||||
|
<button
|
||||||
|
key={second}
|
||||||
|
data-second={second}
|
||||||
|
onClick={() => handleSecondSelect(second)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 text-sm rounded-md transition-colors",
|
||||||
|
"hover:bg-primary/10",
|
||||||
|
selectedSecond === second
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||||
|
: "text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{second.toString().padStart(2, "0")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재 선택된 시간 표시 */}
|
{/* 현재 선택된 시간 표시 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user