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:
유병철
2026-02-15 23:18:45 +09:00
parent 7ce4efa146
commit 7f39f3066f
81 changed files with 12848 additions and 2749 deletions

View File

@@ -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)

View File

@@ -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개

View File

@@ -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 (일반전표입력 추가)

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -1,7 +0,0 @@
'use client';
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
export default function CardTransactionNewPage() {
return <CardTransactionDetailClient initialMode="create" />;
}

View File

@@ -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 />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { GeneralJournalEntry } from '@/components/accounting/GeneralJournalEntry';
export default function GeneralJournalEntryPage() {
return <GeneralJournalEntry />;
}

View File

@@ -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 />;
}

View File

@@ -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}
/>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { TaxInvoiceManagement } from '@/components/accounting/TaxInvoiceManagement';
export default function TaxInvoicesPage() {
return <TaxInvoiceManagement />;
}

View File

@@ -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}
/>
);
}

View File

@@ -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 />;

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
}

View File

@@ -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 />;

View File

@@ -0,0 +1,5 @@
import { BarobillIntegration } from '@/components/settings/BarobillIntegration';
export default function BarobillIntegrationPage() {
return <BarobillIntegration />;
}

View File

@@ -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>
);
}

View File

@@ -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: '엑셀 다운로드에 실패했습니다.',
});
}

View File

@@ -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}
/>
</>
);
}
}

View File

@@ -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: '기타' },
];
];

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: '분개 조회에 실패했습니다.',
});
}

View File

@@ -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

View File

@@ -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 },
];

View File

@@ -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>

View File

@@ -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>
&quot;{deleteTarget?.name}&quot; ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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;
}

View 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}
/>
)}
</>
);
}

View 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) };
}
}

View File

@@ -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>
);
}

View 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,
},
};
}

View 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}
/>
);
}

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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 ?? []) : [];
}

View 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>
);
}

View 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: '',
};
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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}
/>
</>
);
}

View 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: '엑셀 다운로드에 실패했습니다.',
});
}

View 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}
/>
)}
</>
);
}

View 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,
};
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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({

View File

@@ -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; // 기본 렌더링 사용
}
},
};

View File

@@ -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={
<>
&quot;{cardToDelete?.cardName}&quot; ?
<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} />;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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>
);
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck - Legacy file, not in use
'use client';
import { useState, useEffect } from 'react';

View File

@@ -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,
}),
};

View File

@@ -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,
},
};
}

View File

@@ -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} />;
}

View File

@@ -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: '피보험자',
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 조회에 실패했습니다.',
});
}

View 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>
);
}

View 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;
}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
{/* 현재 선택된 시간 표시 */}