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()` 사용
|
||||
- 단건/목록 조회 → `executeServerAction()` 유지
|
||||
- `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)
|
||||
|
||||
@@ -188,6 +188,20 @@ export const remove = service.remove;
|
||||
- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조)
|
||||
- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가)
|
||||
- 제거된 보일러플레이트: `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 마이그레이션 전략**:
|
||||
- Wave A: 1건짜리 단순 파일 20개
|
||||
|
||||
@@ -191,6 +191,7 @@ http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관
|
||||
| **게시판관리** | `/ko/board/board-management` | 🆕 NEW |
|
||||
| **팝업관리** | `/ko/settings/popup-management` | 🆕 NEW |
|
||||
| **알림설정** | `/ko/settings/notification-settings` | 🆕 NEW |
|
||||
| **바로빌연동관리** | `/ko/settings/barobill-integration` | 🆕 NEW |
|
||||
|
||||
```
|
||||
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/board/board-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/card-transactions` | 🆕 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 # 거래처관리
|
||||
@@ -256,6 +262,10 @@ http://localhost:3000/ko/accounting/receivables-status # 미수금 현황
|
||||
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
||||
http://localhost:3000/ko/accounting/card-transactions # 카드 내역 조회
|
||||
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/board/board-management # 🆕 게시판관리
|
||||
http://localhost:3000/ko/settings/popup-management # 🆕 팝업관리
|
||||
http://localhost:3000/ko/settings/barobill-integration # 🆕 바로빌연동관리
|
||||
```
|
||||
|
||||
### Approval
|
||||
@@ -433,6 +444,9 @@ http://localhost:3000/ko/accounting/receivables-status # 미수금 현황
|
||||
http://localhost:3000/ko/accounting/bank-transactions # 입출금 계좌조회
|
||||
http://localhost:3000/ko/accounting/card-transactions # 🆕 카드 내역 조회
|
||||
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
|
||||
@@ -524,6 +538,7 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
'/hr/card-management' // 카드관리 (🆕 NEW)
|
||||
'/board/board-management' // 게시판관리 (🆕 NEW)
|
||||
'/settings/popup-management' // 팝업관리 (🆕 NEW)
|
||||
'/settings/barobill-integration' // 바로빌연동관리 (🆕 NEW)
|
||||
|
||||
// 계정/회사/구독 (사이드바 루트 레벨 별도 메뉴)
|
||||
'/settings/account-info' // 계정정보 (🆕 NEW)
|
||||
@@ -550,6 +565,9 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
'/accounting/bank-transactions' // 입출금 계좌조회
|
||||
'/accounting/card-transactions' // 카드 내역 조회
|
||||
'/accounting/bad-debt-collection' // 악성채권 추심관리
|
||||
'/accounting/tax-invoice-issuance' // 세금계산서 발행 (🆕 NEW)
|
||||
'/accounting/gift-certificates' // 상품권관리 (🆕 NEW)
|
||||
'/accounting/general-journal-entry' // 일반전표입력 (🆕 NEW)
|
||||
|
||||
// Board (게시판)
|
||||
'/board' // 게시판 목록
|
||||
@@ -569,4 +587,4 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
## 작성일
|
||||
|
||||
- 최초 작성: 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';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { CardTransactionInquiry } from '@/components/accounting/CardTransactionInquiry';
|
||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
||||
|
||||
export default function CardTransactionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <CardTransactionDetailClient initialMode="create" />;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* 카드 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
||||
import {
|
||||
getCard,
|
||||
updateCard,
|
||||
deleteCard,
|
||||
} from '@/components/hr/CardManagement/actions';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||
import { getCard } from '@/components/hr/CardManagement/actions';
|
||||
import type { Card } from '@/components/hr/CardManagement/types';
|
||||
|
||||
export default function CardDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const cardId = params.id as string;
|
||||
|
||||
const [card, setCard] = useState<Card | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
||||
const urlMode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function loadCard() {
|
||||
setIsLoading(true);
|
||||
@@ -40,49 +24,28 @@ export default function CardDetailPage() {
|
||||
} else {
|
||||
setError(result.error || '카드를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load card:', err);
|
||||
} catch {
|
||||
setError('카드 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadCard();
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={cardConfig}
|
||||
mode={initialMode}
|
||||
initialData={(card as unknown as Record<string, unknown>) || undefined}
|
||||
itemId={cardId}
|
||||
<CardDetail
|
||||
card={card ?? undefined}
|
||||
mode="view"
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,14 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { CardManagement } from '@/components/hr/CardManagement';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
||||
import { createCard } from '@/components/hr/CardManagement/actions';
|
||||
import type { CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||
|
||||
export default function CardManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// mode=new일 때 등록 화면 표시
|
||||
if (mode === 'new') {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
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 <CardDetail mode="create" />;
|
||||
}
|
||||
|
||||
return <CardManagement />;
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계좌 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
||||
* 계좌 상세/수정 페이지 - AccountDetailForm 적용
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||
import {
|
||||
getBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
} from '@/components/settings/AccountManagement/actions';
|
||||
import type { Account, AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -25,11 +23,9 @@ export default function AccountDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
||||
const urlMode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
||||
const initialMode: 'view' | 'edit' = urlMode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function loadAccount() {
|
||||
setIsLoading(true);
|
||||
@@ -40,50 +36,40 @@ export default function AccountDetailPage() {
|
||||
} else {
|
||||
setError(result.error || '계좌를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load account:', err);
|
||||
} catch {
|
||||
setError('계좌 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadAccount();
|
||||
}, [accountId]);
|
||||
|
||||
// 수정 핸들러
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await updateBankAccount(accountId, data as unknown as Partial<AccountFormData>);
|
||||
const handleSubmit = async (data: AccountFormData) => {
|
||||
const result = await updateBankAccount(accountId, data);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteBankAccount(accountId);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
// 에러 상태
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
<AccountDetailForm
|
||||
mode={initialMode}
|
||||
initialData={(account as unknown as Record<string, unknown>) || undefined}
|
||||
itemId={accountId}
|
||||
initialData={account || undefined}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
stickyButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계좌 등록 페이지 - IntegratedDetailTemplate 적용
|
||||
* 계좌 등록 페이지 - AccountDetailForm 적용
|
||||
*/
|
||||
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
|
||||
export default function NewAccountPage() {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await createBankAccount(data as unknown as AccountFormData);
|
||||
const handleSubmit = async (data: AccountFormData) => {
|
||||
const result = await createBankAccount(data);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
stickyButtons={true}
|
||||
/>
|
||||
);
|
||||
return <AccountDetailForm mode="create" onSubmit={handleSubmit} />;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AccountManagement } from '@/components/settings/AccountManagement';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import { AccountDetailForm } from '@/components/settings/AccountManagement/AccountDetailForm';
|
||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
|
||||
@@ -12,18 +11,12 @@ export default function AccountsPage() {
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await createBankAccount(data as unknown as AccountFormData);
|
||||
const handleSubmit = async (data: AccountFormData) => {
|
||||
const result = await createBankAccount(data);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
return <AccountDetailForm mode="create" onSubmit={handleSubmit} />;
|
||||
}
|
||||
|
||||
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 { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { BankTransaction, TransactionKind } from './types';
|
||||
import type { BankTransaction, TransactionKind, TransactionFormData, AccountCategoryFilter } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface BankTransactionApiItem {
|
||||
id: number;
|
||||
type: 'deposit' | 'withdrawal';
|
||||
transaction_date: string;
|
||||
transaction_time?: string | null;
|
||||
bank_account_id: number;
|
||||
bank_name: string;
|
||||
account_name: string;
|
||||
account_number?: string | null;
|
||||
note: string | null;
|
||||
vendor_id: number | null;
|
||||
vendor_name: string | null;
|
||||
@@ -23,6 +25,10 @@ interface BankTransactionApiItem {
|
||||
balance: number | string;
|
||||
transaction_type: string | null;
|
||||
source_id: string;
|
||||
memo?: string | null;
|
||||
branch?: string | null;
|
||||
is_manual?: boolean;
|
||||
modified_fields?: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -30,6 +36,9 @@ interface BankTransactionApiItem {
|
||||
interface BankTransactionApiSummary {
|
||||
total_deposit: number;
|
||||
total_withdrawal: number;
|
||||
total_balance: number;
|
||||
account_count: number;
|
||||
unset_count: number;
|
||||
deposit_unset_count: number;
|
||||
withdrawal_unset_count: number;
|
||||
}
|
||||
@@ -40,7 +49,9 @@ function transformItem(item: BankTransactionApiItem): BankTransaction {
|
||||
id: `${item.type}-${item.id}`,
|
||||
bankName: item.bank_name,
|
||||
accountName: item.account_name,
|
||||
accountNumber: item.account_number || undefined,
|
||||
transactionDate: item.transaction_date,
|
||||
transactionTime: item.transaction_time || undefined,
|
||||
type: item.type as TransactionKind,
|
||||
note: item.note || 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,
|
||||
transactionType: item.transaction_type || undefined,
|
||||
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,
|
||||
updatedAt: item.updated_at,
|
||||
};
|
||||
@@ -61,6 +77,7 @@ export async function getBankTransactionList(params?: {
|
||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||
bankAccountId?: number; transactionType?: string; search?: string;
|
||||
sortBy?: string; sortDir?: 'asc' | 'desc';
|
||||
accountCategory?: AccountCategoryFilter; financialInstitution?: string;
|
||||
}) {
|
||||
return executePaginatedAction<BankTransactionApiItem, BankTransaction>({
|
||||
url: buildApiUrl('/api/v1/bank-transactions', {
|
||||
@@ -73,6 +90,8 @@ export async function getBankTransactionList(params?: {
|
||||
search: params?.search,
|
||||
sort_by: params?.sortBy,
|
||||
sort_dir: params?.sortDir,
|
||||
account_category: params?.accountCategory !== 'all' ? params?.accountCategory : undefined,
|
||||
financial_institution: params?.financialInstitution !== 'all' ? params?.financialInstitution : undefined,
|
||||
}),
|
||||
transform: transformItem,
|
||||
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?: {
|
||||
startDate?: string; endDate?: string;
|
||||
}): Promise<ActionResult<{ totalDeposit: number; totalWithdrawal: number; depositUnsetCount: number; withdrawalUnsetCount: number }>> {
|
||||
}): Promise<ActionResult<BankTransactionSummaryData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/summary', {
|
||||
start_date: params?.startDate,
|
||||
@@ -91,14 +118,15 @@ export async function getBankTransactionSummary(params?: {
|
||||
transform: (data: BankTransactionApiSummary) => ({
|
||||
totalDeposit: data.total_deposit,
|
||||
totalWithdrawal: data.total_withdrawal,
|
||||
depositUnsetCount: data.deposit_unset_count,
|
||||
withdrawalUnsetCount: data.withdrawal_unset_count,
|
||||
totalBalance: data.total_balance ?? 0,
|
||||
accountCount: data.account_count ?? 0,
|
||||
unsetCount: data.unset_count ?? (data.deposit_unset_count + data.withdrawal_unset_count),
|
||||
}),
|
||||
errorMessage: '요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계좌 목록 조회 (필터용) =====
|
||||
// ===== 계좌 목록 조회 (필터 + 모달 Select용) =====
|
||||
export async function getBankAccountOptions(): Promise<{
|
||||
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 };
|
||||
}
|
||||
|
||||
// ===== 금융기관 목록 조회 (⑤ 필터용) =====
|
||||
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';
|
||||
|
||||
/**
|
||||
* 입출금 계좌조회 - UniversalListPage 마이그레이션
|
||||
* 계좌 입출금 내역
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 서버 사이드 필터링/페이지네이션
|
||||
* - dateRangeSelector (헤더 액션)
|
||||
* - beforeTableContent: 새로고침 버튼
|
||||
* - tableHeaderActions: 3개 Select 필터 (결제계좌, 입출금유형, 정렬)
|
||||
* - tableFooter: 합계 행
|
||||
* - 수정 버튼 (입금/출금 상세 페이지 이동)
|
||||
* 기획서 기준:
|
||||
* - 통계 5개: 입금, 출금, 잔고, 계좌, 거래
|
||||
* - 테이블 11컬럼: 체크박스, No., 거래일시, 구분, 계좌정보, 적요/내용, 입금, 출금, 잔액, 취급점, 상대계좌예금주명
|
||||
* - 필터: ④구분(은행계좌/대출계좌/증권계좌/보험계좌), ⑤금융기관
|
||||
* - 액션: ①저장, 엑셀 다운로드, ②입출금 수기 입력
|
||||
* - 행 클릭 → 수기 입력/수정 모달
|
||||
* - ③수정 영역 하이라이트, ⑥수정 스티커
|
||||
* - 범례: 수기 계좌(🟠) / 연동 계좌(🔵)
|
||||
* - 날짜 프리셋: 이번달, 지난달, D-2월~D-5월
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
@@ -35,85 +39,82 @@ import {
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { BankTransaction, SortOption } from './types';
|
||||
import type { BankTransaction, AccountCategoryFilter, SortOption } from './types';
|
||||
import {
|
||||
TRANSACTION_KIND_LABELS,
|
||||
DEPOSIT_TYPE_LABELS,
|
||||
WITHDRAWAL_TYPE_LABELS,
|
||||
SORT_OPTIONS,
|
||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
} from './types';
|
||||
import {
|
||||
getBankTransactionList,
|
||||
getBankTransactionSummary,
|
||||
getBankAccountOptions,
|
||||
getFinancialInstitutions,
|
||||
batchSaveTransactions,
|
||||
exportBankTransactionsExcel,
|
||||
type BankTransactionSummaryData,
|
||||
} from './actions';
|
||||
import { TransactionFormModal } from './TransactionFormModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||
const tableColumns = [
|
||||
{ key: 'bankName', label: '은행명' },
|
||||
{ key: 'accountName', label: '계좌명' },
|
||||
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
|
||||
{ key: 'transactionDate', label: '거래일시' },
|
||||
{ key: 'type', label: '구분', className: 'text-center' },
|
||||
{ key: 'note', label: '적요' },
|
||||
{ key: 'vendorName', label: '거래처' },
|
||||
{ key: 'depositorName', label: '입금자/수취인' },
|
||||
{ key: 'accountInfo', label: '계좌정보' },
|
||||
{ key: 'note', label: '적요/내용' },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right' },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right' },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right' },
|
||||
{ key: 'transactionType', label: '입출금 유형', className: 'text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
{ key: 'branch', label: '취급점', className: 'text-center' },
|
||||
{ key: 'depositorName', label: '상대계좌예금주명' },
|
||||
];
|
||||
|
||||
// ===== Props =====
|
||||
interface BankTransactionInquiryProps {
|
||||
initialData?: BankTransaction[];
|
||||
initialSummary?: {
|
||||
totalDeposit: number;
|
||||
totalWithdrawal: number;
|
||||
depositUnsetCount: number;
|
||||
withdrawalUnsetCount: number;
|
||||
};
|
||||
initialPagination?: {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
// ===== 기본 Summary =====
|
||||
const DEFAULT_SUMMARY: BankTransactionSummaryData = {
|
||||
totalDeposit: 0,
|
||||
totalWithdrawal: 0,
|
||||
totalBalance: 0,
|
||||
accountCount: 0,
|
||||
unsetCount: 0,
|
||||
};
|
||||
|
||||
export function BankTransactionInquiry({
|
||||
initialData = [],
|
||||
initialSummary,
|
||||
initialPagination,
|
||||
}: BankTransactionInquiryProps) {
|
||||
const router = useRouter();
|
||||
export function BankTransactionInquiry() {
|
||||
// ===== 데이터 상태 =====
|
||||
const [data, setData] = useState<BankTransaction[]>([]);
|
||||
const [summary, setSummary] = useState<BankTransactionSummaryData>(DEFAULT_SUMMARY);
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1, lastPage: 1, perPage: 20, total: 0,
|
||||
});
|
||||
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [data, setData] = useState<BankTransaction[]>(initialData);
|
||||
const [summary, setSummary] = useState(
|
||||
initialSummary || { totalDeposit: 0, totalWithdrawal: 0, depositUnsetCount: 0, withdrawalUnsetCount: 0 }
|
||||
);
|
||||
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 [accountOptions, setAccountOptions] = useState<{ id: number; label: string }[]>([]);
|
||||
const [financialInstitutionOptions, setFinancialInstitutionOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([{ value: 'all', label: '전체' }]);
|
||||
|
||||
// 필터 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [accountFilter, setAccountFilter] = useState<string>('all');
|
||||
const [transactionTypeFilter, setTransactionTypeFilter] = useState<string>('all');
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1);
|
||||
const [isLoading, setIsLoading] = useState(!initialData.length);
|
||||
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
|
||||
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 날짜 범위 상태
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(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 () => {
|
||||
setIsLoading(true);
|
||||
@@ -126,35 +127,37 @@ export function BankTransactionInquiry({
|
||||
};
|
||||
const sortParams = sortMapping[sortOption];
|
||||
|
||||
const [listResult, summaryResult, accountsResult] = await Promise.all([
|
||||
const [listResult, summaryResult, accountsResult, fiResult] = await Promise.all([
|
||||
getBankTransactionList({
|
||||
page: currentPage,
|
||||
perPage: 20,
|
||||
startDate,
|
||||
endDate,
|
||||
bankAccountId: accountFilter !== 'all' ? parseInt(accountFilter, 10) : undefined,
|
||||
transactionType: transactionTypeFilter !== 'all' ? transactionTypeFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
sortBy: sortParams.sortBy,
|
||||
sortDir: sortParams.sortDir,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
}),
|
||||
getBankTransactionSummary({ startDate, endDate }),
|
||||
getBankAccountOptions(),
|
||||
getFinancialInstitutions(),
|
||||
]);
|
||||
|
||||
if (listResult.success) {
|
||||
setData(listResult.data);
|
||||
setPagination(listResult.pagination);
|
||||
}
|
||||
|
||||
if (summaryResult.success && summaryResult.data) {
|
||||
setSummary(summaryResult.data);
|
||||
}
|
||||
|
||||
if (accountsResult.success) {
|
||||
setAccountOptions([
|
||||
setAccountOptions(accountsResult.data);
|
||||
}
|
||||
if (fiResult.success) {
|
||||
setFinancialInstitutionOptions([
|
||||
{ value: 'all', label: '전체' },
|
||||
...accountsResult.data.map((acc) => ({ value: String(acc.id), label: acc.label })),
|
||||
...fiResult.data,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -163,184 +166,236 @@ export function BankTransactionInquiry({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, startDate, endDate, accountFilter, transactionTypeFilter, searchQuery, sortOption]);
|
||||
}, [currentPage, startDate, endDate, searchQuery, sortOption, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
// 데이터 로드 (필터 변경 시)
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleEditClick = useCallback(
|
||||
(item: BankTransaction) => {
|
||||
if (item.type === 'deposit') {
|
||||
router.push(`/ko/accounting/deposits/${item.sourceId}?mode=edit`);
|
||||
} else {
|
||||
router.push(`/ko/accounting/withdrawals/${item.sourceId}?mode=edit`);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const handleRowClick = useCallback((item: BankTransaction) => {
|
||||
setSelectedTransaction(item);
|
||||
setModalMode('edit');
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
const handleCreateClick = useCallback(() => {
|
||||
setSelectedTransaction(null);
|
||||
setModalMode('create');
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalSuccess = useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 유형 라벨 가져오기 =====
|
||||
const getTransactionTypeLabel = useCallback((item: BankTransaction) => {
|
||||
if (!item.transactionType) return '미설정';
|
||||
if (item.type === 'deposit') {
|
||||
return DEPOSIT_TYPE_LABELS[item.transactionType as keyof typeof DEPOSIT_TYPE_LABELS] || item.transactionType;
|
||||
// ① 저장 버튼 (변경사항 일괄 저장)
|
||||
const handleBatchSave = useCallback(async () => {
|
||||
if (localChanges.size === 0) {
|
||||
toast.info('변경된 내용이 없습니다.');
|
||||
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 totalDeposit = data.reduce((sum, item) => sum + item.depositAmount, 0);
|
||||
const totalWithdrawal = data.reduce((sum, item) => sum + item.withdrawalAmount, 0);
|
||||
return { totalDeposit, totalWithdrawal };
|
||||
}, [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 =====
|
||||
const config: UniversalListConfig<BankTransaction> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '입출금 계좌조회',
|
||||
description: '은행 계좌 정보와 입출금 내역을 조회할 수 있습니다',
|
||||
title: '계좌 입출금 내역',
|
||||
description: '은행 계좌의 입출금 내역을 조회하고 관리합니다',
|
||||
icon: Building2,
|
||||
basePath: '/accounting/bank-transactions',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data,
|
||||
totalCount: pagination.total,
|
||||
}),
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 서버 사이드 필터링 (클라이언트 사이드 아님)
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: 20,
|
||||
showCheckbox: true,
|
||||
showRowNumber: true,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '은행명, 계좌명, 거래처, 입금자/수취인 검색...',
|
||||
searchPlaceholder: '계좌명, 적요, 예금주명 검색...',
|
||||
onSearchChange: setSearchQuery,
|
||||
searchFilter: (item: BankTransaction, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.bankName?.toLowerCase().includes(s) ||
|
||||
item.accountName?.toLowerCase().includes(s) ||
|
||||
item.vendorName?.toLowerCase().includes(s) ||
|
||||
item.note?.toLowerCase().includes(s) ||
|
||||
item.depositorName?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (모바일용)
|
||||
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: '계좌 필터',
|
||||
|
||||
// 날짜 선택기 (헤더 액션)
|
||||
// 날짜 선택기 (이번달~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 variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBatchSave}
|
||||
disabled={isBatchSaving || localChanges.size === 0}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{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: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 결제계좌 필터 */}
|
||||
<Select value={accountFilter} onValueChange={setAccountFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="결제계좌" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {pagination.total}건
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => loadData()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 입출금유형 필터 */}
|
||||
<Select value={transactionTypeFilter} onValueChange={setTransactionTypeFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="입출금유형" />
|
||||
</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)}>
|
||||
{/* ④ 구분 */}
|
||||
<Select
|
||||
value={accountCategoryFilter}
|
||||
onValueChange={(v) => setAccountCategoryFilter(v as AccountCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{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>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -348,31 +403,40 @@ export function BankTransactionInquiry({
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 푸터 (합계 행)
|
||||
// 합계 행 + 범례 (체크박스+No.+9데이터 = 12컬럼)
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="text-center" />
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell className="text-right font-bold text-blue-600">
|
||||
{tableTotals.totalDeposit.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-red-600">
|
||||
{tableTotals.totalWithdrawal.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
<>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell />
|
||||
<TableCell colSpan={5} className="text-right font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold text-blue-600">
|
||||
{tableTotals.totalDeposit.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-red-600">
|
||||
{tableTotals.totalWithdrawal.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} 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>
|
||||
</>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
// 통계 카드 5개
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '입금',
|
||||
@@ -387,14 +451,20 @@ export function BankTransactionInquiry({
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '입금 유형 미설정',
|
||||
value: `${summary.depositUnsetCount}건`,
|
||||
label: '잔고',
|
||||
value: `${summary.totalBalance.toLocaleString()}원`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '출금 유형 미설정',
|
||||
value: `${summary.withdrawalUnsetCount}건`,
|
||||
label: '계좌',
|
||||
value: `${summary.accountCount}개`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-gray-500',
|
||||
},
|
||||
{
|
||||
label: '거래',
|
||||
value: `${pagination.total}건`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
@@ -403,76 +473,92 @@ export function BankTransactionInquiry({
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: BankTransaction,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||
) => {
|
||||
const isTypeUnset = item.transactionType === 'unset';
|
||||
const rowModified = isRowModified(item.id);
|
||||
|
||||
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()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
<TableCell className="text-center w-[40px]" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={() => handlers.onToggle()}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 번호 */}
|
||||
{/* No. */}
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
{/* 은행명 */}
|
||||
<TableCell>{item.bankName}</TableCell>
|
||||
{/* 계좌명 */}
|
||||
<TableCell>{item.accountName}</TableCell>
|
||||
{/* 거래일시 */}
|
||||
<TableCell>{item.transactionDate}</TableCell>
|
||||
{/* 구분 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
>
|
||||
{TRANSACTION_KIND_LABELS[item.type]}
|
||||
</Badge>
|
||||
<TableCell>
|
||||
<span className={isCellModified(item, 'transaction_date') ? 'bg-green-100 px-1 rounded' : ''}>
|
||||
{item.transactionDate}
|
||||
{item.transactionTime && (
|
||||
<span className="text-xs text-gray-400 ml-1">{item.transactionTime}</span>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* 구분 (계좌 카테고리) */}
|
||||
<TableCell className="text-center text-sm">
|
||||
{item.accountCategory
|
||||
? ACCOUNT_CATEGORY_LABELS[item.accountCategory]
|
||||
: '은행계좌'}
|
||||
</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 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() : '-'}
|
||||
</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() : '-'}
|
||||
</TableCell>
|
||||
{/* 잔액 */}
|
||||
<TableCell className="text-right font-medium">{item.balance.toLocaleString()}</TableCell>
|
||||
{/* 입출금 유형 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isTypeUnset ? 'border-red-300 text-red-500 bg-red-50' : ''}
|
||||
>
|
||||
{getTransactionTypeLabel(item)}
|
||||
</Badge>
|
||||
<TableCell className="text-right font-medium">
|
||||
{item.balance.toLocaleString()}
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{handlers.isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => handleEditClick(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 취급점 */}
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{item.branch || '-'}
|
||||
</TableCell>
|
||||
{/* 상대계좌예금주명 */}
|
||||
<TableCell className="text-sm">
|
||||
{item.depositorName || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -481,75 +567,91 @@ export function BankTransactionInquiry({
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: BankTransaction,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={`${item.bankName} - ${item.accountName}`}
|
||||
subtitle={item.transactionDate}
|
||||
badge={TRANSACTION_KIND_LABELS[item.type]}
|
||||
badgeVariant="outline"
|
||||
badgeClassName={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
details={[
|
||||
{
|
||||
label: '입금',
|
||||
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{
|
||||
label: '출금',
|
||||
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||
{ label: '거래처', value: item.vendorName || '-' },
|
||||
{ label: '입출금 유형', value: getTransactionTypeLabel(item) },
|
||||
]}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<Button variant="outline" className="w-full" onClick={() => handleEditClick(item)}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> 수정
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
) => {
|
||||
return (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={`${item.bankName || item.accountName}${item.accountNumber ? ` ****${item.accountNumber.slice(-4)}` : ''}`}
|
||||
subtitle={item.transactionDate}
|
||||
badge={TRANSACTION_KIND_LABELS[item.type]}
|
||||
badgeVariant="outline"
|
||||
badgeClassName={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '적요', value: item.note || '-' },
|
||||
{
|
||||
label: '입금',
|
||||
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{
|
||||
label: '출금',
|
||||
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||
{ label: '취급점', value: item.branch || '-' },
|
||||
{ label: '예금주', value: item.depositorName || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
data,
|
||||
pagination,
|
||||
summary,
|
||||
accountOptions,
|
||||
accountFilter,
|
||||
transactionTypeFilter,
|
||||
accountCategoryFilter,
|
||||
financialInstitutionFilter,
|
||||
financialInstitutionOptions,
|
||||
sortOption,
|
||||
startDate,
|
||||
endDate,
|
||||
tableTotals,
|
||||
isLoading,
|
||||
handleRefresh,
|
||||
handleEditClick,
|
||||
getTransactionTypeLabel,
|
||||
isBatchSaving,
|
||||
localChanges,
|
||||
handleBatchSave,
|
||||
handleExcelDownload,
|
||||
handleCreateClick,
|
||||
handleRowClick,
|
||||
isRowModified,
|
||||
isCellModified,
|
||||
loadData,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={data}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages: pagination.lastPage,
|
||||
totalItems: pagination.total,
|
||||
itemsPerPage: 20,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={data}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages: pagination.lastPage,
|
||||
totalItems: pagination.total,
|
||||
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';
|
||||
@@ -30,28 +30,52 @@ export type WithdrawalTransactionType =
|
||||
| 'vatPayment' // 부가세납부
|
||||
| 'other'; // 기타
|
||||
|
||||
// 계좌 카테고리 타입 (구분 컬럼 표시용)
|
||||
export type AccountCategory = 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||
|
||||
// 입출금 거래 레코드
|
||||
export interface BankTransaction {
|
||||
id: string;
|
||||
bankName: string; // 은행명
|
||||
accountName: string; // 계좌명
|
||||
transactionDate: string; // 거래일시
|
||||
type: TransactionKind; // 구분 (입금/출금)
|
||||
note?: string; // 적요
|
||||
accountNumber?: string; // 계좌번호
|
||||
accountCategory?: AccountCategory; // 계좌 카테고리 (은행계좌/대출계좌/증권계좌/보험계좌)
|
||||
transactionDate: string; // 거래일
|
||||
transactionTime?: string; // 거래시간 (HHMMSS)
|
||||
type: TransactionKind; // 입금/출금
|
||||
note?: string; // 적요/내용
|
||||
vendorId?: string; // 거래처 ID
|
||||
vendorName?: string; // 거래처명
|
||||
depositorName?: string; // 입금자/수취인
|
||||
depositorName?: string; // 상대계좌 예금주명
|
||||
depositAmount: number; // 입금
|
||||
withdrawalAmount: number; // 출금
|
||||
balance: number; // 잔액
|
||||
transactionType?: string; // 입출금 유형
|
||||
sourceId: string; // 원본 입금/출금 ID (상세 이동용)
|
||||
sourceId: string; // 원본 입금/출금 ID
|
||||
bankAccountId?: number; // 계좌 ID
|
||||
memo?: string; // 메모
|
||||
branch?: string; // 취급점
|
||||
isManual: boolean; // 수기(true) / 연동(false)
|
||||
modifiedFields?: string[]; // 수정된 필드 목록
|
||||
createdAt: 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';
|
||||
@@ -90,10 +114,21 @@ export const WITHDRAWAL_TYPE_LABELS: Record<WithdrawalTransactionType, string> =
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { value: TransactionFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체(선택)' },
|
||||
{ value: 'deposit', label: '입금/수입' },
|
||||
{ value: 'withdrawal', label: '출금' },
|
||||
// 계좌 카테고리 라벨 (구분 컬럼 표시용)
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountCategory, string> = {
|
||||
bank_account: '은행계좌',
|
||||
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 }[] = [
|
||||
@@ -127,4 +162,4 @@ export const TRANSACTION_TYPE_FILTER_OPTIONS: { value: string; label: string }[]
|
||||
{ value: 'loanRepayment', label: '차입금상환' },
|
||||
{ value: 'vatPayment', 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 { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { CardTransaction } from './types';
|
||||
import type { CardTransaction, ManualInputFormData, InlineEditData, JournalEntryItem } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface CardTransactionApiItem {
|
||||
@@ -14,8 +14,17 @@ interface CardTransactionApiItem {
|
||||
used_at: string | null;
|
||||
merchant_name: string | null;
|
||||
amount: number | string;
|
||||
supply_amount?: number | string;
|
||||
tax_amount?: number | string;
|
||||
business_number?: string | null;
|
||||
account_code: 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: {
|
||||
id: number;
|
||||
card_company: string;
|
||||
@@ -43,26 +52,131 @@ function transformItem(item: CardTransactionApiItem): CardTransaction {
|
||||
const usedAtDate = new Date(usedAtRaw);
|
||||
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 {
|
||||
id: String(item.id),
|
||||
card: cardDisplay,
|
||||
cardCompany: card?.card_company || '-',
|
||||
card: card ? `****${card.card_number_last4}` : '-',
|
||||
cardName: card?.card_name || '-',
|
||||
user: card?.assigned_user?.name || '-',
|
||||
usedAt,
|
||||
merchantName: item.merchant_name || item.description || '-',
|
||||
amount: typeof item.amount === 'string' ? parseFloat(item.amount) : item.amount,
|
||||
merchantName: item.merchant_name || '-',
|
||||
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 || '',
|
||||
createdAt: item.created_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?: {
|
||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||
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', {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
@@ -72,17 +186,29 @@ export async function getCardTransactionList(params?: {
|
||||
search: params?.search,
|
||||
sort_by: params?.sortBy,
|
||||
sort_dir: params?.sortDir,
|
||||
is_hidden: params?.isHidden,
|
||||
}),
|
||||
transform: transformItem,
|
||||
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?: {
|
||||
startDate?: string; endDate?: string;
|
||||
}): Promise<ActionResult<{ previousMonthTotal: number; currentMonthTotal: number; totalCount: number; totalAmount: number }>> {
|
||||
return executeServerAction({
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/card-transactions/summary', {
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
@@ -95,6 +221,15 @@ export async function getCardTransactionSummary(params?: {
|
||||
}),
|
||||
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: {
|
||||
cardId?: number; usedAt: string; merchantName: string; amount: number; memo?: string; usageType?: string;
|
||||
}): Promise<ActionResult<CardTransaction>> {
|
||||
// ===== 카드 거래 등록 (수기 입력) =====
|
||||
export async function createCardTransaction(data: ManualInputFormData): Promise<ActionResult<CardTransaction>> {
|
||||
const usedAt = data.usedTime
|
||||
? `${data.usedDate} ${data.usedTime}`
|
||||
: data.usedDate;
|
||||
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/card-transactions'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
card_id: data.cardId, used_at: data.usedAt, merchant_name: data.merchantName,
|
||||
amount: data.amount, description: data.memo,
|
||||
account_code: data.usageType === 'unset' ? null : data.usageType,
|
||||
card_id: data.cardId ? Number(data.cardId) : undefined,
|
||||
used_at: usedAt,
|
||||
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: '등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -134,7 +283,7 @@ export async function updateCardTransaction(id: string, data: {
|
||||
used_at: data.usedAt, merchant_name: data.merchantName, amount: data.amount,
|
||||
description: data.memo, account_code: data.usageType === 'unset' ? null : data.usageType,
|
||||
},
|
||||
transform: (data: CardTransactionApiItem) => transformItem(data),
|
||||
transform: (resp: CardTransactionApiItem) => transformItem(resp),
|
||||
errorMessage: '수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -177,3 +326,112 @@ export async function bulkUpdateAccountCode(ids: number[], accountCode: string):
|
||||
});
|
||||
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 {
|
||||
id: string;
|
||||
card: string; // 카드 (신한 1234 등)
|
||||
cardCompany: string; // 카드사 (신한, KB 등)
|
||||
card: string; // 카드번호 표시 (****1234 등)
|
||||
cardName: string; // 카드명 (법인카드1 등)
|
||||
user: string; // 사용자
|
||||
usedAt: string; // 사용일시
|
||||
merchantName: string; // 가맹점명
|
||||
amount: number; // 사용금액
|
||||
memo?: string; // 적요
|
||||
usageType: string; // 사용유형
|
||||
businessNumber: string; // 사업자번호
|
||||
vendorName: 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;
|
||||
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';
|
||||
|
||||
@@ -27,6 +90,12 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'amountLow', label: '금액낮은순' },
|
||||
];
|
||||
|
||||
// ===== 공제여부 옵션 =====
|
||||
export const DEDUCTION_OPTIONS = [
|
||||
{ value: 'deductible', label: '공제' },
|
||||
{ value: 'non_deductible', label: '불공제' },
|
||||
];
|
||||
|
||||
// ===== 사용유형 옵션 =====
|
||||
export const USAGE_TYPE_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
@@ -49,9 +118,9 @@ export const USAGE_TYPE_OPTIONS = [
|
||||
{ value: 'miscellaneous', label: '잡비' },
|
||||
];
|
||||
|
||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
||||
// ===== 계정과목 옵션 =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: '', label: '선택' },
|
||||
{ value: 'purchasePayment', label: '매입대금' },
|
||||
{ value: 'advance', label: '선급금' },
|
||||
{ value: 'suspense', label: '가지급금' },
|
||||
@@ -67,4 +136,14 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'expenses', 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>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||
취소
|
||||
</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>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
|
||||
@@ -588,7 +588,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||
취소
|
||||
</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>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||
취소
|
||||
</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 { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { Card, CardFormData, CardStatus } from './types';
|
||||
import type { Card, CardFormData, CardStatus, CardStats, CardListFilter } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
interface TenantProfile {
|
||||
@@ -59,14 +59,24 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
||||
const profile = apiData.assigned_user?.tenant_profiles?.[0];
|
||||
const department = profile?.department;
|
||||
|
||||
const raw = apiData as CardApiData & Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
cardCompany: apiData.card_company as Card['cardCompany'],
|
||||
cardType: (raw.card_type as string) || '',
|
||||
cardNumber: `****-****-****-${apiData.card_number_last4}`,
|
||||
cardName: apiData.card_name,
|
||||
alias: (raw.alias as string) || '',
|
||||
expiryDate: apiData.expiry_date ? apiData.expiry_date.replace('/', '') : '',
|
||||
csv: (raw.csv as string) || '',
|
||||
paymentDay: (raw.payment_day as string) || '',
|
||||
pinPrefix: '**',
|
||||
totalLimit: Number(raw.total_limit) || 0,
|
||||
usedAmount: Number(raw.used_amount) || 0,
|
||||
remainingLimit: Number(raw.remaining_limit) || 0,
|
||||
status: mapApiStatusToFrontend(apiData.status),
|
||||
isManual: (raw.is_manual as boolean) ?? true,
|
||||
user: apiData.assigned_user ? {
|
||||
id: String(apiData.assigned_user.id),
|
||||
departmentId: department ? String(department.id) : '',
|
||||
@@ -76,6 +86,7 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
||||
positionId: profile?.position_key || '',
|
||||
positionName: profile?.position_key || '',
|
||||
} : undefined,
|
||||
memo: (raw.memo as string) || '',
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
@@ -85,11 +96,19 @@ function transformApiToFrontend(apiData: CardApiData): Card {
|
||||
function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {
|
||||
card_company: data.cardCompany,
|
||||
card_type: data.cardType || undefined,
|
||||
card_name: data.cardName,
|
||||
alias: data.alias || undefined,
|
||||
expiry_date: data.expiryDate.length === 4
|
||||
? `${data.expiryDate.slice(0, 2)}/${data.expiryDate.slice(2)}`
|
||||
: 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),
|
||||
memo: data.memo || undefined,
|
||||
};
|
||||
|
||||
const cardNumberDigits = data.cardNumber.replace(/-/g, '');
|
||||
@@ -109,13 +128,16 @@ function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
|
||||
}
|
||||
|
||||
// ===== 카드 목록 조회 =====
|
||||
export async function getCards(params?: {
|
||||
search?: string; status?: string; page?: number; per_page?: number;
|
||||
}): Promise<{ success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> {
|
||||
export async function getCards(params?: Partial<CardListFilter> & { per_page?: number }): Promise<{
|
||||
success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<CardPaginationData>({
|
||||
url: buildApiUrl('/api/v1/cards', {
|
||||
search: params?.search,
|
||||
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,
|
||||
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>> {
|
||||
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';
|
||||
|
||||
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 { CreditCard, Plus, Search, RefreshCw } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { CreditCard, Wallet, PiggyBank, TrendingDown } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type TabOption,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import type { Card } from './types';
|
||||
import type { Card as CardType } from './types';
|
||||
import {
|
||||
CARD_COMPANIES,
|
||||
CARD_STATUS_OPTIONS,
|
||||
CARD_STATUS_LABELS,
|
||||
CARD_STATUS_COLORS,
|
||||
getCardCompanyLabel,
|
||||
} from './types';
|
||||
import { getCards, deleteCard, deleteCards } from './actions';
|
||||
import { getCards, getCardStats } from './actions';
|
||||
|
||||
// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234)
|
||||
const maskCardNumber = (cardNumber: string): string => {
|
||||
return cardNumber;
|
||||
};
|
||||
|
||||
interface CardManagementProps {
|
||||
initialData?: Card[];
|
||||
function formatCurrency(value: number): string {
|
||||
return value.toLocaleString('ko-KR') + '원';
|
||||
}
|
||||
|
||||
export function CardManagement({ initialData }: CardManagementProps) {
|
||||
export function CardManagement() {
|
||||
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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [cardToDelete, setCardToDelete] = useState<Card | null>(null);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
// ===== 날짜 범위 상태 =====
|
||||
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 filteredCards = useMemo(() => {
|
||||
let filtered = cards;
|
||||
// ===== 필터 상태 =====
|
||||
const [cardCompanyFilter, setCardCompanyFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
// 탭 필터 (상태)
|
||||
if (activeTab !== 'all') {
|
||||
filtered = filtered.filter(c => c.status === activeTab);
|
||||
}
|
||||
// ===== 통계 (별도 API) =====
|
||||
const [stats, setStats] = useState<StatCard[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(c =>
|
||||
c.cardName.toLowerCase().includes(search) ||
|
||||
c.cardNumber.includes(search) ||
|
||||
getCardCompanyLabel(c.cardCompany).toLowerCase().includes(search) ||
|
||||
c.user?.employeeName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
getCardStats({ startDate, endDate }).then(result => {
|
||||
if (result.success && result.data) {
|
||||
setStats([
|
||||
{ label: '전체', value: `${result.data.totalCount}개`, icon: CreditCard, iconColor: 'text-blue-500' },
|
||||
{ label: '결제예정', value: formatCurrency(result.data.upcomingPayment), icon: Wallet, iconColor: 'text-orange-500' },
|
||||
{ label: '총한도', value: formatCurrency(result.data.totalLimit), icon: PiggyBank, iconColor: 'text-green-500' },
|
||||
{ label: '잔여한도', value: formatCurrency(result.data.remainingLimit), icon: TrendingDown, iconColor: 'text-purple-500' },
|
||||
]);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback((item: CardType) => {
|
||||
router.push(`/ko/hr/card-management/${item.id}`);
|
||||
}, [router]);
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
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(() => {
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/hr/card-management?mode=new');
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteCard = useCallback(async () => {
|
||||
if (cardToDelete) {
|
||||
const result = await deleteCard(cardToDelete.id);
|
||||
if (result.success) {
|
||||
setCards(prev => prev.filter(card => card.id !== cardToDelete.id));
|
||||
toast.success('카드가 삭제되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setCardToDelete(null);
|
||||
}
|
||||
}, [cardToDelete]);
|
||||
// ===== Config =====
|
||||
const config: UniversalListConfig<CardType> = useMemo(
|
||||
() => ({
|
||||
title: '카드 관리',
|
||||
description: '관련 기능 및 카드 목록을 관리합니다.',
|
||||
icon: CreditCard,
|
||||
basePath: '/hr/card-management',
|
||||
|
||||
const handleRowClick = useCallback((row: Card) => {
|
||||
router.push(`/ko/hr/card-management/${row.id}?mode=view`);
|
||||
}, [router]);
|
||||
idField: 'id',
|
||||
showCheckbox: false,
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const cardManagementConfig: UniversalListConfig<Card> = useMemo(() => ({
|
||||
title: '카드관리',
|
||||
description: '카드 목록을 관리합니다',
|
||||
icon: CreditCard,
|
||||
basePath: '/hr/card-management',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: cards,
|
||||
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;
|
||||
// 날짜 범위 선택기 + 프리셋 버튼
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||
presetLabels: {
|
||||
thisMonth: '이번달',
|
||||
lastMonth: '지난달',
|
||||
},
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
// 수기 카드 등록 버튼
|
||||
createButton: {
|
||||
label: '수기 카드 등록',
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
// 통계카드 (별도 API에서 로드)
|
||||
stats,
|
||||
|
||||
createButton: {
|
||||
label: '카드 등록',
|
||||
icon: Plus,
|
||||
onClick: handleAddCard,
|
||||
},
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
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();
|
||||
return (
|
||||
item.cardName.toLowerCase().includes(search) ||
|
||||
item.cardNumber.includes(search) ||
|
||||
getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) ||
|
||||
(item.user?.employeeName?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
},
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: CardType,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
||||
) => {
|
||||
const usagePercent = item.totalLimit > 0
|
||||
? Math.min(Math.round((item.usedAmount / item.totalLimit) * 100), 100)
|
||||
: 0;
|
||||
|
||||
tabFilter: (item, activeTab) => {
|
||||
if (activeTab === 'all') return true;
|
||||
return item.status === activeTab;
|
||||
},
|
||||
return (
|
||||
<TableRow
|
||||
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;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
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 (
|
||||
// 모바일 카드
|
||||
renderMobileCard: (
|
||||
item: CardType,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
||||
) => (
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getCardCompanyLabel(item.cardCompany)}
|
||||
</span>
|
||||
}
|
||||
statusBadge={
|
||||
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
||||
{CARD_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
isSelected={false}
|
||||
onToggleSelection={() => {}}
|
||||
onClick={() => handleRowClick(item)}
|
||||
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.cardNumber} />
|
||||
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
||||
<InfoField label="직책" value={item.user?.positionName || '-'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={handleDeleteCard}
|
||||
title="카드 삭제"
|
||||
description={
|
||||
<>
|
||||
"{cardToDelete?.cardName}" 카드를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 카드 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
}), [
|
||||
cards,
|
||||
tableColumns,
|
||||
tabs,
|
||||
activeTab,
|
||||
handleAddCard,
|
||||
handleRowClick,
|
||||
deleteDialogOpen,
|
||||
cardToDelete,
|
||||
handleDeleteCard,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UniversalListPage<Card>
|
||||
config={cardManagementConfig}
|
||||
initialData={cards}
|
||||
initialTotalCount={cards.length}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
// 테이블 카드 내부 하단 - 범례
|
||||
tableFooter: (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} 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-2 h-2 rounded-full bg-gray-400" />
|
||||
수기 카드
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-500" />
|
||||
연동 카드
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
}),
|
||||
[handleCreate, handleRowClick, startDate, endDate, cardCompanyFilter, statusFilter, stats]
|
||||
);
|
||||
}
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
// 카드 상태
|
||||
// ===== 카드 상태 =====
|
||||
export type CardStatus = 'active' | 'suspended';
|
||||
|
||||
// 카드 상태 레이블
|
||||
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
|
||||
active: '사용',
|
||||
suspended: '정지',
|
||||
suspended: '중지',
|
||||
};
|
||||
|
||||
// 카드 상태 색상
|
||||
export const CARD_STATUS_COLORS: Record<CardStatus, string> = {
|
||||
active: 'bg-green-100 text-green-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 = [
|
||||
{ value: 'shinhan', label: '신한카드' },
|
||||
{ value: 'kb', label: 'KB국민카드' },
|
||||
@@ -29,7 +33,46 @@ export const CARD_COMPANIES = [
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
@@ -40,33 +83,64 @@ export interface CardUser {
|
||||
positionName: string;
|
||||
}
|
||||
|
||||
// 카드 정보
|
||||
// ===== 카드 정보 =====
|
||||
export interface Card {
|
||||
id: string;
|
||||
cardCompany: CardCompany;
|
||||
cardNumber: string; // 1234-1234-1234-1234
|
||||
cardName: string; // 카드명
|
||||
expiryDate: string; // MMYY
|
||||
pinPrefix: string; // 비밀번호 앞 2자리
|
||||
cardCompany: CardCompany | string;
|
||||
cardType: string;
|
||||
cardNumber: string;
|
||||
cardName: string;
|
||||
alias: string;
|
||||
expiryDate: string;
|
||||
csv: string;
|
||||
paymentDay: string;
|
||||
pinPrefix: string;
|
||||
totalLimit: number;
|
||||
usedAmount: number;
|
||||
remainingLimit: number;
|
||||
status: CardStatus;
|
||||
isManual: boolean;
|
||||
user?: CardUser;
|
||||
memo: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 카드 폼 데이터
|
||||
// ===== 카드 폼 데이터 =====
|
||||
export interface CardFormData {
|
||||
cardCompany: CardCompany | '';
|
||||
cardCompany: CardCompany | string;
|
||||
cardType: string;
|
||||
cardNumber: string;
|
||||
cardName: string;
|
||||
alias: string;
|
||||
expiryDate: string;
|
||||
csv: string;
|
||||
paymentDay: string;
|
||||
pinPrefix: string;
|
||||
totalLimit: number;
|
||||
usedAmount: number;
|
||||
remainingLimit: number;
|
||||
status: CardStatus;
|
||||
userId?: string;
|
||||
departmentId?: string;
|
||||
positionId?: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 카드사 레이블 가져오기
|
||||
export const getCardCompanyLabel = (value: CardCompany): string => {
|
||||
const company = CARD_COMPANIES.find(c => c.value === value);
|
||||
return company?.label || value;
|
||||
};
|
||||
// ===== 통계 =====
|
||||
export interface CardStats {
|
||||
totalCount: number;
|
||||
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> = {
|
||||
thisYear: '당해년도',
|
||||
fiveMonthsAgo: 'D-5월',
|
||||
fourMonthsAgo: 'D-4월',
|
||||
threeMonthsAgo: 'D-3월',
|
||||
twoMonthsAgo: '전전월',
|
||||
lastMonth: '전월',
|
||||
thisMonth: '당월',
|
||||
@@ -39,6 +42,8 @@ interface DateRangeSelectorProps {
|
||||
onEndDateChange: (date: string) => void;
|
||||
/** 표시할 프리셋 목록 (기본: 전체) */
|
||||
presets?: DatePreset[];
|
||||
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||
presetLabels?: Partial<Record<DatePreset, string>>;
|
||||
/** 추가 액션 (엑셀 다운로드, 등록 버튼 등) */
|
||||
extraActions?: ReactNode;
|
||||
/** 프리셋 버튼 숨김 */
|
||||
@@ -78,6 +83,7 @@ export function DateRangeSelector({
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
presets = DEFAULT_PRESETS,
|
||||
presetLabels: customLabels,
|
||||
extraActions,
|
||||
hidePresets = false,
|
||||
hideDateInputs = false,
|
||||
@@ -120,6 +126,24 @@ export function DateRangeSelector({
|
||||
onStartDateChange(format(today, 'yyyy-MM-dd'));
|
||||
onEndDateChange(format(today, 'yyyy-MM-dd'));
|
||||
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]);
|
||||
|
||||
@@ -136,7 +160,7 @@ export function DateRangeSelector({
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
{customLabels?.[preset] || PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</ScrollableButtonGroup>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface StatCardData {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
@@ -21,8 +22,14 @@ interface 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 (
|
||||
<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) => {
|
||||
const Icon = stat.icon;
|
||||
const isClickable = !!stat.onClick;
|
||||
@@ -42,6 +49,9 @@ export function StatCards({ stats }: StatCardsProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
||||
{stat.label}
|
||||
{stat.sublabel && (
|
||||
<span className="ml-2 normal-case tracking-normal">{stat.sublabel}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="font-bold text-base md:text-lg truncate">
|
||||
{stat.value}
|
||||
|
||||
@@ -51,12 +51,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<AccountFormData>({
|
||||
category: account?.category || 'bank_account',
|
||||
accountType: account?.accountType || 'savings',
|
||||
bankCode: account?.bankCode || '',
|
||||
bankName: account?.bankName || '',
|
||||
accountNumber: account?.accountNumber || '',
|
||||
accountName: account?.accountName || '',
|
||||
accountHolder: account?.accountHolder || '',
|
||||
accountPassword: '',
|
||||
status: account?.status || 'active',
|
||||
});
|
||||
|
||||
@@ -121,11 +122,12 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
// 원래 데이터로 복원
|
||||
if (account) {
|
||||
setFormData({
|
||||
category: account.category || 'bank_account',
|
||||
accountType: account.accountType || 'savings',
|
||||
bankCode: account.bankCode, bankName: account.bankName,
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
accountHolder: account.accountHolder,
|
||||
accountPassword: '',
|
||||
status: account.status,
|
||||
});
|
||||
}
|
||||
@@ -292,15 +294,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountPassword">
|
||||
계좌 비밀번호 (빠른 조회 서비스)
|
||||
</Label>
|
||||
<Label htmlFor="accountName">계좌명</Label>
|
||||
<Input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
value={formData.accountPassword}
|
||||
onChange={(e) => handleChange('accountPassword', e.target.value)}
|
||||
placeholder="****"
|
||||
id="accountName2"
|
||||
value={formData.accountName}
|
||||
onChange={(e) => handleChange('accountName', e.target.value)}
|
||||
placeholder="계좌명"
|
||||
disabled
|
||||
/>
|
||||
</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';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
@@ -97,7 +97,6 @@ export const accountConfig: DetailConfig<Account> = {
|
||||
accountNumber: formData.accountNumber as string,
|
||||
accountName: formData.accountName as string,
|
||||
accountHolder: formData.accountHolder as string,
|
||||
accountPassword: formData.accountPassword as string,
|
||||
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 type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
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';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
@@ -21,6 +21,35 @@ interface BankAccountApiData {
|
||||
assigned_user_id?: number;
|
||||
created_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>;
|
||||
@@ -39,23 +68,83 @@ function transformApiToFrontend(apiData: BankAccountApiData): Account {
|
||||
assignedUserId: apiData.assigned_user_id,
|
||||
createdAt: apiData.created_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> {
|
||||
return {
|
||||
// 공통
|
||||
category: data.category,
|
||||
account_type: data.accountType,
|
||||
bank_code: data.bankCode,
|
||||
bank_name: data.bankName || BANK_LABELS[data.bankCode || ''] || data.bankCode,
|
||||
account_number: data.accountNumber,
|
||||
account_holder: data.accountHolder,
|
||||
account_name: data.accountName,
|
||||
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?: {
|
||||
page?: number; perPage?: number; search?: string;
|
||||
page?: number; perPage?: number; search?: string; category?: string;
|
||||
}): Promise<{
|
||||
success: boolean; data?: Account[]; meta?: { currentPage: number; lastPage: number; perPage: number; total: number };
|
||||
error?: string; __authError?: boolean;
|
||||
@@ -65,6 +154,7 @@ export async function getBankAccounts(params?: {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
search: params?.search,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
}),
|
||||
transform: (data: BankAccountPaginatedResponse) => ({
|
||||
accounts: (data?.data || []).map(transformApiToFrontend),
|
||||
@@ -156,3 +246,28 @@ export async function deleteBankAccounts(ids: number[]): Promise<{
|
||||
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';
|
||||
|
||||
/**
|
||||
* 계좌관리 - UniversalListPage 마이그레이션
|
||||
* 계좌관리 - 종합 계좌 관리 목록 페이지
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (전체 데이터 로드 후 필터)
|
||||
* - 삭제/일괄삭제 다이얼로그
|
||||
* - 통계카드 5개 (전체/은행/대출/증권/보험)
|
||||
* - 구분/금융기관 필터
|
||||
* - 수기 계좌 등록 버튼
|
||||
* - 범례 (수기/연동)
|
||||
* - 체크박스 없음
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import {
|
||||
Landmark,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Building2,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
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,
|
||||
type ListParams,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import type { Account } from './types';
|
||||
import type { Account, AccountCategory } from './types';
|
||||
import {
|
||||
BANK_LABELS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_STATUS_LABELS,
|
||||
ACCOUNT_STATUS_COLORS,
|
||||
ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||
} from './types';
|
||||
import { getBankAccounts, deleteBankAccount, deleteBankAccounts } from './actions';
|
||||
import { getBankAccounts } from './actions';
|
||||
|
||||
// ===== 계좌번호 마스킹 함수 =====
|
||||
// ===== 계좌번호 마스킹 =====
|
||||
const maskAccountNumber = (accountNumber: string): string => {
|
||||
if (accountNumber.length <= 8) return accountNumber;
|
||||
if (!accountNumber || accountNumber.length <= 8) return accountNumber || '';
|
||||
const parts = accountNumber.split('-');
|
||||
if (parts.length >= 3) {
|
||||
// 1234-****-****-1234 형태
|
||||
return parts.map((part, idx) => {
|
||||
if (idx === 0 || idx === parts.length - 1) return part;
|
||||
return '****';
|
||||
}).join('-');
|
||||
}
|
||||
// 단순 형태: 앞 4자리-****-뒤 4자리
|
||||
const first = accountNumber.slice(0, 4);
|
||||
const last = accountNumber.slice(-4);
|
||||
return `${first}-****-****-${last}`;
|
||||
return `${first}-****-${last}`;
|
||||
};
|
||||
|
||||
export function AccountManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
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 [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
// ===== 필터 상태 =====
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [institutionFilter, setInstitutionFilter] = useState<string>('all');
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback((item: Account) => {
|
||||
router.push(`/ko/settings/accounts/${item.id}?mode=view`);
|
||||
}, [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(() => {
|
||||
router.push('/ko/settings/accounts?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 금융기관 필터 옵션 =====
|
||||
const institutionFilterOptions = useMemo(() => [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||
], []);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Account> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '계좌관리',
|
||||
title: '계좌 관리',
|
||||
description: '계좌 목록을 관리합니다',
|
||||
icon: Landmark,
|
||||
basePath: '/settings/accounts',
|
||||
|
||||
// ID 추출
|
||||
idField: '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 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getBankAccounts();
|
||||
if (result.success && 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) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = result.data.filter(item =>
|
||||
item.accountName.toLowerCase().includes(search) ||
|
||||
item.accountNumber.includes(search) ||
|
||||
item.accountHolder.toLowerCase().includes(search) ||
|
||||
BANK_LABELS[item.bankCode]?.toLowerCase().includes(search)
|
||||
const s = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(item =>
|
||||
item.accountName?.toLowerCase().includes(s) ||
|
||||
item.accountNumber?.includes(s) ||
|
||||
item.accountHolder?.toLowerCase().includes(s) ||
|
||||
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: [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'bank', label: '은행', className: 'min-w-[100px]' },
|
||||
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||
{ 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: 'accountName', label: '계좌명', className: 'min-w-[120px]' },
|
||||
{ key: 'accountHolder', label: '예금주', className: 'min-w-[80px]' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[70px]' },
|
||||
],
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '은행명, 계좌번호, 계좌명, 예금주 검색...',
|
||||
searchPlaceholder: '금융기관, 계좌번호, 계좌명 검색...',
|
||||
searchFilter: (item: Account, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
@@ -214,21 +237,47 @@ export function AccountManagement() {
|
||||
);
|
||||
},
|
||||
|
||||
// 헤더 액션
|
||||
// 헤더 액션 - 수기 계좌 등록 버튼
|
||||
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" />
|
||||
계좌 등록
|
||||
수기 계좌 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
onBulkDelete: handleBulkDelete,
|
||||
// 테이블 카드 내부 필터 (구분, 금융기관 Select) - "총 N건" 옆에 배치
|
||||
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: (
|
||||
item: Account,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||
) => {
|
||||
@@ -238,49 +287,40 @@ export function AccountManagement() {
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
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>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
|
||||
<TableCell className="font-mono">{maskAccountNumber(item.accountNumber)}</TableCell>
|
||||
<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.accountHolder}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
|
||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</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>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
// 모바일 카드
|
||||
renderMobileCard: (
|
||||
item: Account,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||
) => {
|
||||
@@ -292,7 +332,7 @@ export function AccountManagement() {
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
{ACCOUNT_CATEGORY_LABELS[item.category]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{BANK_LABELS[item.bankCode] || item.bankCode}
|
||||
@@ -304,85 +344,39 @@ export function AccountManagement() {
|
||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
isSelected={false}
|
||||
onToggleSelection={() => {}}
|
||||
onClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<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={item.accountHolder} />
|
||||
</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 (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,65 @@ export const ACCOUNT_STATUS_COLORS: Record<AccountStatus, string> = {
|
||||
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 = [
|
||||
{ value: 'shinhan', label: '신한은행' },
|
||||
{ value: 'kb', label: 'KB국민은행' },
|
||||
@@ -41,7 +99,44 @@ export const BANK_OPTIONS = [
|
||||
{ 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 }),
|
||||
{}
|
||||
);
|
||||
@@ -59,25 +154,123 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
// ===== 계좌 인터페이스 =====
|
||||
export interface Account {
|
||||
id: number;
|
||||
bankCode: string; // 은행 코드
|
||||
bankName: string; // 은행명
|
||||
accountNumber: string; // 계좌번호
|
||||
accountName: string; // 계좌명
|
||||
accountHolder: string; // 예금주
|
||||
status: AccountStatus; // 상태 (사용/정지)
|
||||
isPrimary: boolean; // 대표 계좌 여부
|
||||
assignedUserId?: number; // 담당자 ID
|
||||
bankCode: string;
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
accountHolder: string;
|
||||
status: AccountStatus;
|
||||
isPrimary: boolean;
|
||||
assignedUserId?: number;
|
||||
createdAt: 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 {
|
||||
// 공통
|
||||
category: AccountCategory;
|
||||
accountType: string;
|
||||
bankCode: string;
|
||||
bankName: string; // 은행명 (bank_code에서 매핑)
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
accountHolder: string;
|
||||
accountPassword: string; // 빠른 조회 서비스용 (클라이언트 전용, API 미전송)
|
||||
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;
|
||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||
hideDateInputs?: boolean;
|
||||
/** 표시할 프리셋 목록 */
|
||||
presets?: import('@/components/molecules/DateRangeSelector').DatePreset[];
|
||||
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||
presetLabels?: Partial<Record<import('@/components/molecules/DateRangeSelector').DatePreset, string>>;
|
||||
/** 프리셋 버튼 위치 */
|
||||
presetsPosition?: 'inline' | 'below';
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (date: string) => void;
|
||||
@@ -603,6 +609,9 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
onEndDateChange={dateRangeSelector.onEndDateChange || (() => {})}
|
||||
hidePresets={dateRangeSelector.showPresets === false}
|
||||
hideDateInputs={dateRangeSelector.hideDateInputs}
|
||||
presets={dateRangeSelector.presets}
|
||||
presetLabels={dateRangeSelector.presetLabels}
|
||||
presetsPosition={dateRangeSelector.presetsPosition}
|
||||
extraActions={
|
||||
<>
|
||||
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
||||
@@ -944,7 +953,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
onClick={isSortable ? () => onSort(column.key) : undefined}
|
||||
>
|
||||
{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>
|
||||
{isSortable && (
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
@@ -283,6 +283,10 @@ export interface UniversalListConfig<T> {
|
||||
showPresets?: boolean;
|
||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||
hideDateInputs?: boolean;
|
||||
/** 표시할 프리셋 목록 */
|
||||
presets?: import('@/components/molecules/DateRangeSelector').DatePreset[];
|
||||
/** 프리셋 레이블 커스텀 오버라이드 */
|
||||
presetLabels?: Partial<Record<import('@/components/molecules/DateRangeSelector').DatePreset, string>>;
|
||||
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||
presetsPosition?: 'inline' | 'below';
|
||||
startDate?: string;
|
||||
|
||||
@@ -12,13 +12,18 @@ import {
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
interface TimePickerProps {
|
||||
value?: string; // "HH:mm" format
|
||||
/** "HH:mm" 또는 showSeconds 시 "HH:mm:ss" 형식 */
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** 분 단위 간격 (기본값: 5) */
|
||||
minuteStep?: number;
|
||||
/** 초 선택 표시 여부 (기본값: false) */
|
||||
showSeconds?: boolean;
|
||||
/** 초 단위 간격 (기본값: 5) */
|
||||
secondStep?: number;
|
||||
}
|
||||
|
||||
function TimePicker({
|
||||
@@ -28,14 +33,16 @@ function TimePicker({
|
||||
disabled = false,
|
||||
className,
|
||||
minuteStep = 5,
|
||||
showSeconds = false,
|
||||
secondStep = 5,
|
||||
}: TimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// 현재 선택된 시/분 파싱
|
||||
const [selectedHour, selectedMinute] = React.useMemo(() => {
|
||||
if (!value) return [null, null];
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
return [h, m];
|
||||
// 현재 선택된 시/분/초 파싱
|
||||
const [selectedHour, selectedMinute, selectedSecond] = React.useMemo(() => {
|
||||
if (!value) return [null, null, null];
|
||||
const parts = value.split(":").map(Number);
|
||||
return [parts[0] ?? null, parts[1] ?? null, parts[2] ?? null];
|
||||
}, [value]);
|
||||
|
||||
// 시간 배열 생성 (0-23)
|
||||
@@ -47,18 +54,35 @@ function TimePicker({
|
||||
(_, 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 minute = selectedMinute ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
onChange?.(buildTimeValue(hour, selectedMinute ?? 0, selectedSecond ?? 0));
|
||||
};
|
||||
|
||||
// 분 선택 핸들러
|
||||
const handleMinuteSelect = (minute: number) => {
|
||||
const hour = selectedHour ?? 0;
|
||||
const newValue = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
onChange?.(newValue);
|
||||
onChange?.(buildTimeValue(selectedHour ?? 0, minute, selectedSecond ?? 0));
|
||||
};
|
||||
|
||||
// 초 선택 핸들러
|
||||
const handleSecondSelect = (second: number) => {
|
||||
onChange?.(buildTimeValue(selectedHour ?? 0, selectedMinute ?? 0, second));
|
||||
};
|
||||
|
||||
// 표시할 시간 텍스트
|
||||
@@ -67,6 +91,7 @@ function TimePicker({
|
||||
// 스크롤 영역 ref
|
||||
const hourScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const minuteScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const secondScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 팝오버 열릴 때 선택된 시간으로 스크롤
|
||||
React.useEffect(() => {
|
||||
@@ -84,9 +109,15 @@ function TimePicker({
|
||||
);
|
||||
minuteElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
if (showSeconds && selectedSecond !== null && secondScrollRef.current) {
|
||||
const secondElement = secondScrollRef.current.querySelector(
|
||||
`[data-second="${selectedSecond}"]`
|
||||
);
|
||||
secondElement?.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [open, selectedHour, selectedMinute]);
|
||||
}, [open, selectedHour, selectedMinute, selectedSecond, showSeconds]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -171,6 +202,40 @@ function TimePicker({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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>
|
||||
|
||||
{/* 현재 선택된 시간 표시 */}
|
||||
|
||||
Reference in New Issue
Block a user