From 7f39f3066f2be223e7aef4be6493d511a258e742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Sun, 15 Feb 2026 23:18:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=ED=9A=8C=EA=B3=84/=EC=84=A4?= =?UTF-8?q?=EC=A0=95/=EC=B9=B4=EB=93=9C=20=EA=B4=80=EB=A6=AC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=8C=80=EA=B7=9C=EB=AA=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + claudedocs/_index.md | 14 + claudedocs/dev/[REF] all-pages-test-urls.md | 20 +- .../card-transactions/[id]/edit/page.tsx | 13 - .../card-transactions/[id]/page.tsx | 13 - .../accounting/card-transactions/new/page.tsx | 7 - .../accounting/card-transactions/page.tsx | 9 - .../accounting/general-journal-entry/page.tsx | 7 + .../accounting/gift-certificates/page.tsx | 54 + .../accounting/tax-invoice-issuance/page.tsx | 78 ++ .../accounting/tax-invoices/page.tsx | 7 + .../hr/card-management/[id]/page.tsx | 55 +- .../(protected)/hr/card-management/page.tsx | 19 +- .../settings/accounts/[id]/page.tsx | 32 +- .../settings/accounts/new/page.tsx | 18 +- .../(protected)/settings/accounts/page.tsx | 15 +- .../settings/barobill-integration/page.tsx | 5 + .../TransactionFormModal.tsx | 445 +++++++ .../BankTransactionInquiry/actions.ts | 153 ++- .../BankTransactionInquiry/index.tsx | 744 ++++++----- .../BankTransactionInquiry/types.ts | 61 +- .../CardTransactionDetailClient.tsx | 138 -- .../JournalEntryModal.tsx | 294 +++++ .../ManualInputModal.tsx | 322 +++++ .../CardTransactionInquiry/actions.ts | 288 ++++- .../cardTransactionDetailConfig.ts | 127 -- .../CardTransactionInquiry/index.tsx | 1123 ++++++++--------- .../CardTransactionInquiry/types.ts | 95 +- .../accounting/DepositManagement/index.tsx | 2 +- .../AccountSubjectSettingModal.tsx | 370 ++++++ .../GeneralJournalEntry/JournalEditModal.tsx | 514 ++++++++ .../ManualJournalEntryModal.tsx | 401 ++++++ .../accounting/GeneralJournalEntry/actions.ts | 316 +++++ .../accounting/GeneralJournalEntry/index.tsx | 423 +++++++ .../accounting/GeneralJournalEntry/types.ts | 266 ++++ .../GiftCertificateDetail.tsx | 286 +++++ .../GiftCertificateManagement/actions.ts | 155 +++ .../GiftCertificateManagement/index.tsx | 378 ++++++ .../GiftCertificateManagement/types.ts | 106 ++ .../accounting/PurchaseManagement/index.tsx | 2 +- .../accounting/SalesManagement/index.tsx | 2 +- .../SupplierSettingModal.tsx | 144 +++ .../TaxInvoiceIssuance/TaxInvoiceDetail.tsx | 206 +++ .../TaxInvoiceIssuance/TaxInvoiceForm.tsx | 215 ++++ .../TaxInvoiceItemTable.tsx | 237 ++++ .../accounting/TaxInvoiceIssuance/actions.ts | 175 +++ .../accounting/TaxInvoiceIssuance/index.tsx | 482 +++++++ .../accounting/TaxInvoiceIssuance/types.ts | 155 +++ .../TaxInvoiceManagement/CardHistoryModal.tsx | 178 +++ .../JournalEntryModal.tsx | 411 ++++++ .../TaxInvoiceManagement/ManualEntryModal.tsx | 291 +++++ .../TaxInvoiceManagement/actions.ts | 187 +++ .../accounting/TaxInvoiceManagement/index.tsx | 551 ++++++++ .../accounting/TaxInvoiceManagement/types.ts | 266 ++++ .../accounting/WithdrawalManagement/index.tsx | 2 +- .../hr/CardManagement/CardDetail.tsx | 602 +++++++++ .../CardManagement/CardManagementUnified.tsx | 266 ---- .../hr/CardManagement/_legacy/CardDetail.tsx | 132 -- .../hr/CardManagement/_legacy/CardForm.tsx | 246 ---- src/components/hr/CardManagement/actions.ts | 62 +- .../hr/CardManagement/cardConfig.ts | 165 --- src/components/hr/CardManagement/index.tsx | 539 ++++---- src/components/hr/CardManagement/types.ts | 112 +- .../molecules/DateRangeSelector.tsx | 28 +- src/components/organisms/StatCards.tsx | 12 +- .../AccountManagement/AccountDetail.tsx | 20 +- .../AccountManagement/AccountDetailForm.tsx | 814 ++++++++++++ .../_legacy/AccountDetail.tsx | 1 + .../AccountManagement/accountConfig.ts | 1 - .../settings/AccountManagement/actions.ts | 119 +- .../settings/AccountManagement/index.tsx | 424 +++---- .../settings/AccountManagement/types.ts | 217 +++- .../BarobillIntegration/BankServiceModal.tsx | 132 ++ .../BarobillIntegration/LoginModal.tsx | 111 ++ .../BarobillIntegration/SignupModal.tsx | 210 +++ .../settings/BarobillIntegration/actions.ts | 102 ++ .../settings/BarobillIntegration/index.tsx | 241 ++++ .../settings/BarobillIntegration/types.ts | 57 + .../templates/IntegratedListTemplateV2.tsx | 11 +- .../templates/UniversalListPage/types.ts | 4 + src/components/ui/time-picker.tsx | 91 +- 81 files changed, 12848 insertions(+), 2749 deletions(-) delete mode 100644 src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx delete mode 100644 src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx delete mode 100644 src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/general-journal-entry/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/gift-certificates/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/tax-invoice-issuance/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/tax-invoices/page.tsx create mode 100644 src/app/[locale]/(protected)/settings/barobill-integration/page.tsx create mode 100644 src/components/accounting/BankTransactionInquiry/TransactionFormModal.tsx delete mode 100644 src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx create mode 100644 src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx create mode 100644 src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx delete mode 100644 src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts create mode 100644 src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx create mode 100644 src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx create mode 100644 src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx create mode 100644 src/components/accounting/GeneralJournalEntry/actions.ts create mode 100644 src/components/accounting/GeneralJournalEntry/index.tsx create mode 100644 src/components/accounting/GeneralJournalEntry/types.ts create mode 100644 src/components/accounting/GiftCertificateManagement/GiftCertificateDetail.tsx create mode 100644 src/components/accounting/GiftCertificateManagement/actions.ts create mode 100644 src/components/accounting/GiftCertificateManagement/index.tsx create mode 100644 src/components/accounting/GiftCertificateManagement/types.ts create mode 100644 src/components/accounting/TaxInvoiceIssuance/SupplierSettingModal.tsx create mode 100644 src/components/accounting/TaxInvoiceIssuance/TaxInvoiceDetail.tsx create mode 100644 src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx create mode 100644 src/components/accounting/TaxInvoiceIssuance/TaxInvoiceItemTable.tsx create mode 100644 src/components/accounting/TaxInvoiceIssuance/actions.ts create mode 100644 src/components/accounting/TaxInvoiceIssuance/index.tsx create mode 100644 src/components/accounting/TaxInvoiceIssuance/types.ts create mode 100644 src/components/accounting/TaxInvoiceManagement/CardHistoryModal.tsx create mode 100644 src/components/accounting/TaxInvoiceManagement/JournalEntryModal.tsx create mode 100644 src/components/accounting/TaxInvoiceManagement/ManualEntryModal.tsx create mode 100644 src/components/accounting/TaxInvoiceManagement/actions.ts create mode 100644 src/components/accounting/TaxInvoiceManagement/index.tsx create mode 100644 src/components/accounting/TaxInvoiceManagement/types.ts create mode 100644 src/components/hr/CardManagement/CardDetail.tsx delete mode 100644 src/components/hr/CardManagement/CardManagementUnified.tsx delete mode 100644 src/components/hr/CardManagement/_legacy/CardDetail.tsx delete mode 100644 src/components/hr/CardManagement/_legacy/CardForm.tsx delete mode 100644 src/components/hr/CardManagement/cardConfig.ts create mode 100644 src/components/settings/AccountManagement/AccountDetailForm.tsx create mode 100644 src/components/settings/BarobillIntegration/BankServiceModal.tsx create mode 100644 src/components/settings/BarobillIntegration/LoginModal.tsx create mode 100644 src/components/settings/BarobillIntegration/SignupModal.tsx create mode 100644 src/components/settings/BarobillIntegration/actions.ts create mode 100644 src/components/settings/BarobillIntegration/index.tsx create mode 100644 src/components/settings/BarobillIntegration/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 72ba961b..ca070ed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,6 +334,7 @@ const form = useForm({ - 페이지네이션 조회 → `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) diff --git a/claudedocs/_index.md b/claudedocs/_index.md index e122de36..7572266d 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -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개 diff --git a/claudedocs/dev/[REF] all-pages-test-urls.md b/claudedocs/dev/[REF] all-pages-test-urls.md index abd27a48..4c14b5c9 100644 --- a/claudedocs/dev/[REF] all-pages-test-urls.md +++ b/claudedocs/dev/[REF] all-pages-test-urls.md @@ -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 (일반전표입력 추가) diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx deleted file mode 100644 index 78cc3685..00000000 --- a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/edit/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx deleted file mode 100644 index 1701bef1..00000000 --- a/src/app/[locale]/(protected)/accounting/card-transactions/[id]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx deleted file mode 100644 index 396e55fc..00000000 --- a/src/app/[locale]/(protected)/accounting/card-transactions/new/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient'; - -export default function CardTransactionNewPage() { - return ; -} diff --git a/src/app/[locale]/(protected)/accounting/card-transactions/page.tsx b/src/app/[locale]/(protected)/accounting/card-transactions/page.tsx index 425b48e2..a74a7aeb 100644 --- a/src/app/[locale]/(protected)/accounting/card-transactions/page.tsx +++ b/src/app/[locale]/(protected)/accounting/card-transactions/page.tsx @@ -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 ; - } - return ; } diff --git a/src/app/[locale]/(protected)/accounting/general-journal-entry/page.tsx b/src/app/[locale]/(protected)/accounting/general-journal-entry/page.tsx new file mode 100644 index 00000000..0d9713f6 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/general-journal-entry/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { GeneralJournalEntry } from '@/components/accounting/GeneralJournalEntry'; + +export default function GeneralJournalEntryPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/gift-certificates/page.tsx b/src/app/[locale]/(protected)/accounting/gift-certificates/page.tsx new file mode 100644 index 00000000..9a9879a1 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/gift-certificates/page.tsx @@ -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(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 ; + } + + if (mode === 'edit' && id) { + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + return ( + + ); + } + + // 목록 - 컴포넌트가 자체 데이터 로딩 처리 + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/tax-invoice-issuance/page.tsx b/src/app/[locale]/(protected)/accounting/tax-invoice-issuance/page.tsx new file mode 100644 index 00000000..a0fcf890 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/tax-invoice-issuance/page.tsx @@ -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([]); + const [supplierSettings, setSupplierSettings] = useState(createEmptyBusinessEntity()); + const [editData, setEditData] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + if (mode === 'edit' && id) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/accounting/tax-invoices/page.tsx b/src/app/[locale]/(protected)/accounting/tax-invoices/page.tsx new file mode 100644 index 00000000..5341bb4f --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/tax-invoices/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { TaxInvoiceManagement } from '@/components/accounting/TaxInvoiceManagement'; + +export default function TaxInvoicesPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx index a6e97eca..adfd1168 100644 --- a/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/hr/card-management/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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) => { - 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 (
-
- {error} -
+
{error}
); } return ( - ) || undefined} - itemId={cardId} + ); } diff --git a/src/app/[locale]/(protected)/hr/card-management/page.tsx b/src/app/[locale]/(protected)/hr/card-management/page.tsx index 3abe2de1..6fc8eee2 100644 --- a/src/app/[locale]/(protected)/hr/card-management/page.tsx +++ b/src/app/[locale]/(protected)/hr/card-management/page.tsx @@ -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) => { - const result = await createCard(data as unknown as CardFormData); - return { success: result.success, error: result.error }; - }; - - return ( - - ); + return ; } return ; diff --git a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx index b0d5cd87..a9a6c82d 100644 --- a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx @@ -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(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) => { - const result = await updateBankAccount(accountId, data as unknown as Partial); + 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 (
-
- {error} -
+
{error}
); } return ( - ) || undefined} - itemId={accountId} + initialData={account || undefined} isLoading={isLoading} onSubmit={handleSubmit} onDelete={handleDelete} - stickyButtons={true} /> ); } diff --git a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx index 923dde40..9b120dd3 100644 --- a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx @@ -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) => { - 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 ( - - ); + return ; } diff --git a/src/app/[locale]/(protected)/settings/accounts/page.tsx b/src/app/[locale]/(protected)/settings/accounts/page.tsx index ecc06ba2..99bb9b53 100644 --- a/src/app/[locale]/(protected)/settings/accounts/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/page.tsx @@ -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) => { - 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 ( - - ); + return ; } return ; diff --git a/src/app/[locale]/(protected)/settings/barobill-integration/page.tsx b/src/app/[locale]/(protected)/settings/barobill-integration/page.tsx new file mode 100644 index 00000000..4b024e9e --- /dev/null +++ b/src/app/[locale]/(protected)/settings/barobill-integration/page.tsx @@ -0,0 +1,5 @@ +import { BarobillIntegration } from '@/components/settings/BarobillIntegration'; + +export default function BarobillIntegrationPage() { + return ; +} diff --git a/src/components/accounting/BankTransactionInquiry/TransactionFormModal.tsx b/src/components/accounting/BankTransactionInquiry/TransactionFormModal.tsx new file mode 100644 index 00000000..338e5549 --- /dev/null +++ b/src/components/accounting/BankTransactionInquiry/TransactionFormModal.tsx @@ -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(() => + getInitialFormData(transaction) + ); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + + // 원본 데이터 (수정 감지용) + const [originalFormData, setOriginalFormData] = useState(() => + 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( + (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 ( + + + + 입출금 수기 입력 + + +
+ {/* 계좌 * (①) */} +
+ + +
+ + {/* 거래일 * / 거래시간 */} +
+
+ + handleChange('transactionDate', date)} + placeholder="날짜 선택" + /> +
+
+ + handleChange('transactionTime', time)} + placeholder="시간 선택" + showSeconds + secondStep={1} + minuteStep={1} + /> +
+
+ + {/* 거래유형 * */} +
+ + handleChange('type', v as TransactionKind)} + className="flex gap-6" + > +
+ + +
+
+ + +
+
+
+ + {/* 금액 * / 잔액 (자동계산) */} +
+
+ + { + const raw = e.target.value.replace(/[^0-9]/g, ''); + handleChange('amount', raw ? parseInt(raw, 10) : 0); + }} + /> +
+
+ + +
+
+ + {/* 적요 + 수정 스티커 (②) */} +
+
+ + {mode === 'edit' && isFieldModified('note') && ( + + 수정 + + )} +
+ handleChange('note', e.target.value)} + placeholder="내용" + /> +
+ + {/* 상대계좌 예금주명 */} +
+ + handleChange('depositorName', e.target.value)} + placeholder="예금주명" + /> +
+ + {/* 메모 */} +
+ + handleChange('memo', e.target.value)} + /> +
+ + {/* 취급점 */} +
+ + handleChange('branch', e.target.value)} + /> +
+
+ + {/* 하단 버튼 */} +
+ {/* 좌측: 원본으로 복원 (③) - 수정 모드에서만 */} +
+ {mode === 'edit' && ( + + )} +
+ + {/* 우측: 삭제 + 수정/등록 */} +
+ {mode === 'edit' && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/components/accounting/BankTransactionInquiry/actions.ts b/src/components/accounting/BankTransactionInquiry/actions.ts index 75ed09d7..5896a2f6 100644 --- a/src/components/accounting/BankTransactionInquiry/actions.ts +++ b/src/components/accounting/BankTransactionInquiry/actions.ts @@ -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({ 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> { +}): Promise> { 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> { + 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 +): Promise> { + 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> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/bank-transactions/${id}`), + method: 'DELETE', + errorMessage: '거래 삭제에 실패했습니다.', + }); +} + +// ===== 원본으로 복원 ===== +export async function restoreTransaction(id: string): Promise> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/bank-transactions/${id}/restore`), + method: 'POST', + transform: transformItem, + errorMessage: '원본 복원에 실패했습니다.', + }); +} + +// ===== 변경사항 일괄 저장 ===== +export async function batchSaveTransactions( + changes: { id: string; data: Partial }[] +): Promise> { + 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> { + 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: '엑셀 다운로드에 실패했습니다.', + }); +} diff --git a/src/components/accounting/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index 2d44d994..8e811171 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -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([]); + const [summary, setSummary] = useState(DEFAULT_SUMMARY); + const [pagination, setPagination] = useState({ + currentPage: 1, lastPage: 1, perPage: 20, total: 0, + }); - // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== - const [data, setData] = useState(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('latest'); - const [accountFilter, setAccountFilter] = useState('all'); - const [transactionTypeFilter, setTransactionTypeFilter] = useState('all'); - const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); - const [isLoading, setIsLoading] = useState(!initialData.length); + const [accountCategoryFilter, setAccountCategoryFilter] = useState('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(null); + + // 수정 추적 (로컬 변경사항) + const [localChanges, setLocalChanges] = useState>>(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 = 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: () => ( - +
+ + + +
), - // 테이블 헤더 액션 (3개 필터) + // 테이블 헤더 액션: 총 N건 + ④구분 + ⑤금융기관 tableHeaderActions: () => (
- {/* 결제계좌 필터 */} - + + 총 {pagination.total}건 + + - {/* 입출금유형 필터 */} - - - {/* 정렬 */} - setAccountCategoryFilter(v as AccountCategoryFilter)} + > - + - {SORT_OPTIONS.map((option) => ( - - {option.label} + {ACCOUNT_CATEGORY_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + {/* ⑤ 금융기관 */} + +
+ + + {/* 계정과목 + 공제 + 증빙/판매자상호 */} +
+ {/* Select - FormField 예외 */} +
+ + +
+ {/* Select - FormField 예외 */} +
+ + +
+ updateItem(index, 'vendorName', v)} + placeholder="내용" + inputClassName="h-8 text-sm" + /> +
+ + {/* 내역 + 메모 */} +
+ updateItem(index, 'description', v)} + placeholder="내역" + inputClassName="h-8 text-sm" + /> + updateItem(index, 'memo', v)} + inputClassName="h-8 text-sm" + /> +
+ + ))} + + {/* 분개 항목 추가 버튼 */} + + + {/* 분개 합계 */} +
+ 분개 합계 + {journalTotal.toLocaleString()}원 +
+ + + + + + + + + ); +} diff --git a/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx new file mode 100644 index 00000000..26b87a73 --- /dev/null +++ b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx @@ -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(initialFormData); + const [cardOptions, setCardOptions] = useState>([]); + 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 ( + + + + 카드사용 수기 입력 + + +
+ {/* 카드 선택 (동적 API Select - FormField 예외) */} +
+ + +
+ + {/* 사용일 + 사용시간 (공통 DatePicker/TimePicker) */} +
+
+ + handleChange('usedDate', v)} + placeholder="날짜 선택" + className="mt-1" + /> +
+
+ + handleChange('usedTime', v)} + placeholder="시간 선택" + showSeconds + secondStep={1} + minuteStep={1} + className="mt-1" + /> +
+
+ + {/* 승인번호 + 승인유형 */} +
+ handleChange('approvalNumber', v)} + placeholder="승인번호" + /> +
+ + handleChange('approvalType', v)} + className="flex items-center gap-4 mt-2" + > +
+ + +
+
+ + +
+
+
+
+ + {/* 공급가액 + 세액 */} +
+ handleChange('supplyAmount', Number(v) || 0)} + placeholder="0" + /> + handleChange('taxAmount', Number(v) || 0)} + placeholder="0" + /> +
+ + {/* 가맹점명 + 사업자번호 */} +
+ handleChange('merchantName', v)} + placeholder="가맹점명" + /> + handleChange('businessNumber', v)} + placeholder="123-12-12345" + /> +
+ + {/* 공제여부 + 계정과목 (Select - FormField 예외) */} +
+
+ + +
+
+ + +
+
+ + {/* 증빙/판매자상호 + 내역 */} +
+ handleChange('vendorName', v)} + placeholder="증빙/판매자상호" + /> + handleChange('description', v)} + placeholder="내역" + /> +
+ + {/* 메모 */} + handleChange('memo', v)} + rows={3} + /> + + {/* 합계 금액 */} +
+ 합계 금액 (공급가액 + 세액) + {totalAmount.toLocaleString()}원 +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/accounting/CardTransactionInquiry/actions.ts b/src/components/accounting/CardTransactionInquiry/actions.ts index 342c6b4d..92bf1ee9 100644 --- a/src/components/accounting/CardTransactionInquiry/actions.ts +++ b/src/components/accounting/CardTransactionInquiry/actions.ts @@ -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({ + const result = await executePaginatedAction({ 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> { - 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> { +// ===== 카드 거래 등록 (수기 입력) ===== +export async function createCardTransaction(data: ManualInputFormData): Promise> { + 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 +): Promise { + 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 { + return executeServerAction({ + url: buildApiUrl(`/api/v1/card-transactions/${id}/hide`), + method: 'PUT', + errorMessage: '숨김 처리에 실패했습니다.', + }); +} + +// ===== 거래 숨김 해제 (복원) ===== +export async function unhideTransaction(id: string): Promise { + 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({ + 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 { + 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> { + 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: '분개 조회에 실패했습니다.', + }); +} diff --git a/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts b/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts deleted file mode 100644 index c13c79a1..00000000 --- a/src/components/accounting/CardTransactionInquiry/cardTransactionDetailConfig.ts +++ /dev/null @@ -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): Record => { - const record = data as unknown as CardTransaction; - // DevFill에서 전달된 cardId 또는 기존 데이터의 cardId - const inputCardId = (data as Record).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) => { - 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, - }; - }, -}; \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index d525bec0..3cdb36ad 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -1,45 +1,28 @@ 'use client'; /** - * 카드 내역 조회 - UniversalListPage 마이그레이션 + * 카드 사용내역 (기획서 D1.5) * - * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 - * - 클라이언트 사이드 필터링/페이지네이션 - * - dateRangeSelector (헤더 액션) - * - beforeTableContent: 계정과목명 선택 + 저장 버튼 + 새로고침 - * - tableHeaderActions: 2개 Select 필터 (카드명, 정렬) - * - tableFooter: 합계 행 - * - showRowNumber={false} - * - 상세 모달 (수정 기능) - * - 계정과목명 일괄 저장 다이얼로그 + * UniversalListPage 기반 마이그레이션 (BankTransactionInquiry 패턴) + * + * 테이블 15 데이터 컬럼: + * 사용일시, 카드사, 카드번호, 카드명, 공제, 사업자번호, + * 가맹점명, 증빙/판매자상호, 내역, 합계금액, 공급가액, 세액, 계정과목, 분개, 숨김 */ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; -import { CreditCard, Plus, RefreshCw, Save, Loader2, Search } from 'lucide-react'; +import { + CreditCard, Save, Download, Eye, EyeOff, + Plus, Loader2, RotateCcw, RefreshCw, +} from 'lucide-react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { TableRow, TableCell } from '@/components/ui/table'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; import { Select, SelectContent, @@ -55,97 +38,73 @@ import { type RowClickHandlers, type StatCard, } from '@/components/templates/UniversalListPage'; -import type { CardTransaction, SortOption } from './types'; -import { SORT_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, USAGE_TYPE_OPTIONS } from './types'; +import type { CardTransaction, InlineEditData, SortOption } from './types'; +import { + SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, +} from './types'; import { getCardTransactionList, getCardTransactionSummary, - bulkUpdateAccountCode, + bulkSaveInlineEdits, + hideTransaction, + unhideTransaction, + getHiddenTransactions, } from './actions'; +import { ManualInputModal } from './ManualInputModal'; +import { JournalEntryModal } from './JournalEntryModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; -// ===== 테이블 컬럼 정의 ===== +// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== const tableColumns = [ - { key: 'card', label: '카드' }, - { key: 'cardName', label: '카드명' }, - { key: 'user', label: '사용자' }, - { key: 'usedAt', label: '사용일시' }, - { key: 'merchantName', label: '가맹점명' }, - { key: 'amount', label: '사용금액', className: 'text-right' }, - { key: 'usageType', label: '사용유형' }, + { key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' }, + { key: 'usedAt', label: '사용일시', className: 'min-w-[130px]' }, + { key: 'cardCompany', label: '카드사', className: 'min-w-[80px]' }, + { key: 'card', label: '카드번호', className: 'min-w-[100px]' }, + { key: 'cardName', label: '카드명', className: 'min-w-[80px]' }, + { key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false }, + { key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' }, + { key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' }, + { key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false }, + { key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false }, + { key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' }, + { key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false }, + { key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false }, + { key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false }, + { key: 'journalEntry', label: '분개', className: 'w-16 text-center', sortable: false }, + { key: 'hide', label: '숨김', className: 'w-16 text-center', sortable: false }, ]; -// ===== Props ===== -interface CardTransactionInquiryProps { - initialData?: CardTransaction[]; - initialSummary?: { - previousMonthTotal: number; - currentMonthTotal: number; - }; - initialPagination?: { - currentPage: number; - lastPage: number; - perPage: number; - total: number; - }; -} +export function CardTransactionInquiry() { + // ===== 데이터 상태 ===== + const [data, setData] = useState([]); + const [hiddenData, setHiddenData] = useState([]); + const [summary, setSummary] = useState({ previousMonthTotal: 0, currentMonthTotal: 0, totalCount: 0 }); + const [pagination, setPagination] = useState({ currentPage: 1, lastPage: 1, perPage: 20, total: 0 }); + const [isLoading, setIsLoading] = useState(true); + const isInitialLoadDone = useRef(false); + const isLocalHiddenModified = useRef(false); // 로컬 숨김/복원 수행 시 API 리로드 방지 -export function CardTransactionInquiry({ - initialData = [], - initialSummary, - initialPagination, -}: CardTransactionInquiryProps) { - const router = useRouter(); - - // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== - const [data, setData] = useState(initialData); - const [summary, setSummary] = useState( - initialSummary || { previousMonthTotal: 0, currentMonthTotal: 0 } - ); - const [pagination, setPagination] = useState( - initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 } - ); - - // 필터 상태 + // ===== 필터 상태 ===== + const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); const [searchQuery, setSearchQuery] = useState(''); const [sortOption, setSortOption] = useState('latest'); const [cardFilter, setCardFilter] = useState('all'); - const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); - const [isLoading, setIsLoading] = useState(!initialData.length); - const isInitialLoadDone = useRef(false); - const itemsPerPage = 20; + const [currentPage, setCurrentPage] = useState(1); - // 상단 계정과목명 선택 (저장용) - const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); + // ===== 인라인 편집 상태 ===== + const [inlineEdits, setInlineEdits] = useState>({}); - // 계정과목명 저장 다이얼로그 - const [showSaveDialog, setShowSaveDialog] = useState(false); + // ===== UI 상태 ===== + const [showHiddenSection, setShowHiddenSection] = useState(false); + const [showManualInput, setShowManualInput] = useState(false); + const [showJournalEntry, setShowJournalEntry] = useState(false); + const [journalTransaction, setJournalTransaction] = useState(null); const [isSaving, setIsSaving] = useState(false); - // 선택 필요 알림 다이얼로그 - const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false); - - // 상세 모달 상태 - const [showDetailModal, setShowDetailModal] = useState(false); - const [selectedItem, setSelectedItem] = useState(null); - const [detailFormData, setDetailFormData] = useState({ - memo: '', - usageType: 'unset', - }); - const [isDetailSaving, setIsDetailSaving] = useState(false); - - // 선택된 항목 (외부 관리) - const [selectedItems, setSelectedItems] = useState>(new Set()); - - // 날짜 범위 상태 - const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); - const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); - // ===== 데이터 로드 ===== const loadData = useCallback(async () => { - if (!isInitialLoadDone.current) { - setIsLoading(true); - } + if (!isInitialLoadDone.current) setIsLoading(true); try { const sortMapping: Record = { latest: { sortBy: 'used_at', sortDir: 'desc' }, @@ -158,7 +117,7 @@ export function CardTransactionInquiry({ const [listResult, summaryResult] = await Promise.all([ getCardTransactionList({ page: currentPage, - perPage: itemsPerPage, + perPage: 20, startDate, endDate, search: searchQuery || undefined, @@ -172,11 +131,11 @@ export function CardTransactionInquiry({ setData(listResult.data); setPagination(listResult.pagination); } - if (summaryResult.success && summaryResult.data) { setSummary({ previousMonthTotal: summaryResult.data.previousMonthTotal, currentMonthTotal: summaryResult.data.currentMonthTotal, + totalCount: summaryResult.data.totalCount, }); } } catch (error) { @@ -188,592 +147,600 @@ export function CardTransactionInquiry({ } }, [currentPage, startDate, endDate, searchQuery, sortOption]); - // 데이터 로드 (필터 변경 시) - useEffect(() => { - loadData(); - }, [loadData]); + useEffect(() => { loadData(); }, [loadData]); - // ===== 카드명 옵션 ===== + // ===== 숨김 거래 로드 ===== + const loadHiddenData = useCallback(async () => { + // 로컬 숨김/복원 수행 후에는 API 리로드 스킵 (목데이터가 로컬 변경을 덮어쓰는 것 방지) + if (isLocalHiddenModified.current) return; + try { + const result = await getHiddenTransactions({ startDate, endDate }); + if (result.success) setHiddenData(result.data); + } catch (error) { + if (isNextRedirectError(error)) throw error; + } + }, [startDate, endDate]); + + useEffect(() => { + if (showHiddenSection) loadHiddenData(); + }, [showHiddenSection, loadHiddenData]); + + // ===== 카드 필터 옵션 ===== const cardOptions = useMemo(() => { - const uniqueCards = [...new Set(data.map((d) => d.cardName))]; + const uniqueCards = [...new Set(data.map(d => d.cardName))]; return [ { value: 'all', label: '전체' }, - ...uniqueCards.map((card) => ({ value: card, label: card })), + ...uniqueCards.map(card => ({ value: card, label: card })), ]; }, [data]); // ===== 필터링된 데이터 ===== const filteredData = useMemo(() => { - let result = data.filter( - (item) => - item.card.includes(searchQuery) || - item.cardName.includes(searchQuery) || - item.user.includes(searchQuery) || - item.merchantName.includes(searchQuery) - ); - - // 카드명 필터 + let result = [...data]; if (cardFilter !== 'all') { - result = result.filter((item) => item.cardName === cardFilter); + result = result.filter(item => item.cardName === cardFilter); } - - // 정렬 - switch (sortOption) { - case 'oldest': - result.sort((a, b) => new Date(a.usedAt).getTime() - new Date(b.usedAt).getTime()); - break; - case 'amountHigh': - result.sort((a, b) => b.amount - a.amount); - break; - case 'amountLow': - result.sort((a, b) => a.amount - b.amount); - break; - default: // latest - result.sort((a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime()); - break; - } - return result; - }, [data, searchQuery, cardFilter, sortOption]); + }, [data, cardFilter]); - // ===== 핸들러 ===== - const handleRowClick = useCallback((item: CardTransaction) => { - setSelectedItem(item); - setDetailFormData({ - memo: item.memo || '', - usageType: item.usageType || 'unset', - }); - setShowDetailModal(true); + // ===== 인라인 편집 핸들러 ===== + const getEditValue = useCallback((id: string, key: keyof InlineEditData, original: T): T => { + const edited = inlineEdits[id]?.[key]; + return (edited !== undefined ? edited : original) as T; + }, [inlineEdits]); + + const handleInlineEdit = useCallback((id: string, key: keyof InlineEditData, value: string | number) => { + setInlineEdits(prev => ({ + ...prev, + [id]: { ...prev[id], [key]: value }, + })); }, []); - const handleDetailSave = useCallback(async () => { - if (!selectedItem) return; - - setIsDetailSaving(true); - try { - // 임시: 로컬 데이터 업데이트 - setData((prev) => - prev.map((item) => - item.id === selectedItem.id - ? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType } - : item - ) - ); - - setShowDetailModal(false); - setSelectedItem(null); - } catch (error) { - console.error('[CardTransactionInquiry] handleDetailSave error:', error); - } finally { - setIsDetailSaving(false); - } - }, [selectedItem, detailFormData]); - - const handleRefresh = useCallback(() => { - loadData(); - }, [loadData]); - - // ===== 계정과목명 저장 핸들러 ===== - const handleSaveAccountSubject = useCallback(() => { - if (selectedItems.size === 0) { - setShowSelectWarningDialog(true); + // ===== 저장 핸들러 ===== + const handleSave = useCallback(async () => { + if (Object.keys(inlineEdits).length === 0) { + toast.info('변경된 항목이 없습니다.'); return; } - setShowSaveDialog(true); - }, [selectedItems.size]); - - const handleConfirmSaveAccountSubject = useCallback(async () => { - if (selectedAccountSubject === 'unset') return; - setIsSaving(true); try { - const ids = Array.from(selectedItems).map((id) => parseInt(id, 10)); - const result = await bulkUpdateAccountCode(ids, selectedAccountSubject); - + const result = await bulkSaveInlineEdits(inlineEdits); if (result.success) { + toast.success('저장되었습니다.'); + setInlineEdits({}); await loadData(); - setSelectedItems(new Set()); - setSelectedAccountSubject('unset'); } else { - console.error('[CardTransactionInquiry] bulkUpdate error:', result.error); + toast.error(result.error || '저장에 실패했습니다.'); } - } catch (error) { - console.error('[CardTransactionInquiry] bulkUpdate error:', error); + } catch { + toast.error('저장 중 오류가 발생했습니다.'); } finally { setIsSaving(false); - setShowSaveDialog(false); } - }, [selectedAccountSubject, selectedItems, loadData]); + }, [inlineEdits, loadData]); - // ===== 사용유형 라벨 변환 함수 ===== - const getUsageTypeLabel = useCallback((value: string) => { - return USAGE_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || '미설정'; + // ===== 숨김/복원/분개 핸들러 ===== + const handleHide = useCallback(async (id: string) => { + // API 호출 시도, 실패 시(목데이터) 클라이언트 사이드 처리 + try { + const result = await hideTransaction(id); + if (result.success) { + toast.success('숨김 처리되었습니다.'); + setShowHiddenSection(true); + await loadData(); + await loadHiddenData(); + return; + } + } catch { /* API 실패 → 로컬 폴백 */ } + + // 로컬 폴백: data → hiddenData 이동 + const item = data.find(d => d.id === id); + if (item) { + isLocalHiddenModified.current = true; // API 리로드 방지 + setData(prev => prev.filter(d => d.id !== id)); + setHiddenData(prev => [...prev, { ...item, isHidden: true, hiddenAt: new Date().toISOString().slice(0, 16).replace('T', ' ') }]); + setShowHiddenSection(true); + toast.success('숨김 처리되었습니다.'); + } + }, [data, loadData, loadHiddenData]); + + const handleUnhide = useCallback(async (id: string) => { + // API 호출 시도, 실패 시(목데이터) 클라이언트 사이드 처리 + try { + const result = await unhideTransaction(id); + if (result.success) { + toast.success('복원되었습니다.'); + await loadData(); + await loadHiddenData(); + return; + } + } catch { /* API 실패 → 로컬 폴백 */ } + + // 로컬 폴백: hiddenData → data 복원 + const item = hiddenData.find(d => d.id === id); + if (item) { + isLocalHiddenModified.current = true; // API 리로드 방지 + setHiddenData(prev => prev.filter(d => d.id !== id)); + setData(prev => [...prev, { ...item, isHidden: false, hiddenAt: undefined }]); + toast.success('복원되었습니다.'); + } + }, [hiddenData, loadData, loadHiddenData]); + + const handleJournalEntry = useCallback((item: CardTransaction) => { + setJournalTransaction(item); + setShowJournalEntry(true); }, []); - // ===== 테이블 합계 계산 ===== - const totalAmount = useMemo(() => { - return filteredData.reduce((sum, item) => sum + item.amount, 0); - }, [filteredData]); + const handleExcelDownload = useCallback(() => { + toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.'); + }, []); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( () => ({ - // 페이지 기본 정보 - title: '카드 내역 조회', - description: '법인카드 사용 내역을 조회합니다', + title: '카드 사용내역', + description: '카드 사용내역을 조회하고 관리합니다', icon: CreditCard, basePath: '/accounting/card-transactions', - - // ID 추출 idField: 'id', - // API 액션 actions: { - getList: async () => { - return { - success: true, - data: filteredData, - totalCount: filteredData.length, - }; - }, + getList: async () => ({ + success: true, + data: filteredData, + totalCount: pagination.total, + }), }, - // 테이블 컬럼 columns: tableColumns, - - // 클라이언트 사이드 필터링 - clientSideFiltering: true, - itemsPerPage, - - // 행 번호 숨기기 - showRowNumber: false, + clientSideFiltering: false, + itemsPerPage: 20, + showCheckbox: true, + showRowNumber: true, // 검색 - searchPlaceholder: '카드, 카드명, 사용자, 가맹점명 검색...', - searchFilter: (item, searchValue) => { - const search = searchValue.toLowerCase(); + searchPlaceholder: '카드명, 가맹점명, 내역 검색...', + onSearchChange: setSearchQuery, + searchFilter: (item: CardTransaction, search: string) => { + const s = search.toLowerCase(); return ( - item.card.toLowerCase().includes(search) || - item.cardName.toLowerCase().includes(search) || - item.user.toLowerCase().includes(search) || - item.merchantName.toLowerCase().includes(search) + item.cardName?.toLowerCase().includes(s) || + item.merchantName?.toLowerCase().includes(s) || + item.description?.toLowerCase().includes(s) || + false ); }, - // 필터 설정 (모바일용) - filterConfig: [ - { - key: 'card', - label: '카드', - type: 'single', - options: cardOptions.filter((o) => o.value !== 'all'), - }, - { - key: 'sortBy', - label: '정렬', - type: 'single', - options: SORT_OPTIONS.map((o) => ({ - value: o.value, - label: o.label, - })), - }, - ], - initialFilters: { - card: 'all', - sortBy: 'latest', - }, - filterTitle: '카드 필터', - - // 헤더 액션: 계정과목명 Select + 저장 + 새로고침 - headerActions: () => ( -
- 계정과목명 - - - -
- ), - - // 등록 버튼 - createButton: { - label: '카드내역 등록', - icon: Plus, - onClick: () => router.push('/ko/accounting/card-transactions?mode=new'), - }, - - // 커스텀 필터 함수 - customFilterFn: (items) => { - if (!items || items.length === 0) return items; - let result = [...items]; - - // 검색어 필터 - if (searchQuery) { - const search = searchQuery.toLowerCase(); - result = result.filter((item) => - item.card.toLowerCase().includes(search) || - item.cardName.toLowerCase().includes(search) || - item.user.toLowerCase().includes(search) || - item.merchantName.toLowerCase().includes(search) - ); - } - - // 카드명 필터 - if (cardFilter !== 'all') { - result = result.filter((item) => item.cardName === cardFilter); - } - - return result; - }, - - // 커스텀 정렬 함수 - customSortFn: (items) => { - const sorted = [...items]; - - switch (sortOption) { - case 'oldest': - sorted.sort((a, b) => new Date(a.usedAt).getTime() - new Date(b.usedAt).getTime()); - break; - case 'amountHigh': - sorted.sort((a, b) => b.amount - a.amount); - break; - case 'amountLow': - sorted.sort((a, b) => a.amount - b.amount); - break; - default: // latest - sorted.sort((a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime()); - break; - } - - return sorted; - }, - - // 날짜 선택기 (헤더 액션) - // 검색창 (공통 컴포넌트에서 자동 생성) - hideSearch: true, - searchValue: searchQuery, - onSearchChange: setSearchQuery, - + // 날짜 선택기 (이번달~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, }, - // 선택 항목 변경 콜백 - onSelectionChange: setSelectedItems, + // 헤더 액션: 숨김보기 + 저장 + 엑셀 다운로드 + 수기 입력 + headerActions: () => ( +
+ + + + +
+ ), - // 테이블 헤더 액션 (2개 필터) + // 테이블 헤더 액션: 총 N건 + 새로고침 + 카드필터 + 정렬 tableHeaderActions: () => (
- {/* 카드명 필터 */} + + 총 {pagination.total}건 + + - - {/* 정렬 */} - setSortOption(v as SortOption)}> + + - {SORT_OPTIONS.map((option) => ( - - {option.label} - + {SORT_OPTIONS.map(opt => ( + {opt.label} ))}
), - // 테이블 푸터 (합계 행) + // 범례 (수기 카드 / 연동 카드) tableFooter: ( - - - - 합계 + + +
+ + + 수기 카드 + + + + 연동 카드 + +
- {totalAmount.toLocaleString()} -
), - // Stats 카드 + // 통계 카드 3개 computeStats: (): StatCard[] => [ { - label: '전월 사용액', + label: '전월', value: `${summary.previousMonthTotal.toLocaleString()}원`, icon: CreditCard, iconColor: 'text-gray-500', }, { - label: '당월 사용액', + label: '당월', value: `${summary.currentMonthTotal.toLocaleString()}원`, icon: CreditCard, iconColor: 'text-blue-500', }, + { + label: '건수', + value: `${summary.totalCount}건`, + icon: CreditCard, + iconColor: 'text-orange-500', + }, ], - // 테이블 행 렌더링 + // 상세 보기 없음 (인라인 편집 방식) + detailMode: 'none', + + // 테이블 행 렌더링 (17컬럼: 체크박스 + No. + 15데이터) renderTableRow: ( item: CardTransaction, - index: number, + _index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers - ) => ( - handleRowClick(item)} - > - {/* 체크박스 */} - e.stopPropagation()}> - - - {/* 카드 */} - {item.card} - {/* 카드명 */} - {item.cardName} - {/* 사용자 */} - {item.user} - {/* 사용일시 */} - {item.usedAt} - {/* 가맹점명 */} - {item.merchantName} - {/* 사용금액 */} - {item.amount.toLocaleString()} - {/* 사용유형 */} - {getUsageTypeLabel(item.usageType)} - - ), + ) => { + return ( + + {/* 체크박스 */} + e.stopPropagation()}> + handlers.onToggle()} + /> + + {/* No. */} + {globalIndex} + {/* 사용일시 */} + {item.usedAt} + {/* 카드사 (수기/연동 색상 표시) */} + +
+ + {item.cardCompany} +
+
+ {/* 카드번호 */} + {item.card} + {/* 카드명 */} + {item.cardName} + {/* 공제 (인라인 Select) */} + e.stopPropagation()}> + + + {/* 사업자번호 */} + {item.businessNumber} + {/* 가맹점명 */} + {item.merchantName} + {/* 증빙/판매자상호 (인라인 Input) */} + e.stopPropagation()}> + handleInlineEdit(item.id, 'vendorName', e.target.value)} + placeholder="증빙/판매자상호" + className="h-7 text-xs w-[120px]" + /> + + {/* 내역 (인라인 Input) */} + e.stopPropagation()}> + handleInlineEdit(item.id, 'description', e.target.value)} + placeholder="내역" + className="h-7 text-xs w-[110px]" + /> + + {/* 합계금액 */} + {item.totalAmount.toLocaleString()} + {/* 공급가액 (인라인 숫자 Input) */} + e.stopPropagation()}> + handleInlineEdit(item.id, 'supplyAmount', Number(e.target.value) || 0)} + className="h-7 text-xs w-[100px] text-right" + /> + + {/* 세액 (인라인 숫자 Input) */} + e.stopPropagation()}> + handleInlineEdit(item.id, 'taxAmount', Number(e.target.value) || 0)} + className="h-7 text-xs w-[80px] text-right" + /> + + {/* 계정과목 (인라인 Select) */} + e.stopPropagation()}> + + + {/* 분개 버튼 */} + e.stopPropagation()}> + + + {/* 숨김 버튼 */} + e.stopPropagation()}> + + +
+ ); + }, // 모바일 카드 렌더링 renderMobileCard: ( item: CardTransaction, - index: number, - globalIndex: number, + _index: number, + _globalIndex: number, handlers: SelectionHandlers & RowClickHandlers - ) => ( - handleRowClick(item)} - details={[ - { label: '가맹점명', value: item.merchantName }, - { label: '사용금액', value: `${item.amount.toLocaleString()}원` }, - { label: '사용유형', value: getUsageTypeLabel(item.usageType) }, - ]} - /> + ) => { + return ( + + ); + }, + + // 숨김 거래 섹션 (ReactNode로 직접 전달 - 함수 평가 경로 우회) + afterTableContent: showHiddenSection ? ( +
+
+ +

+ 숨김 처리된 거래 ({hiddenData.length}건) +

+
+ {hiddenData.length === 0 ? ( +

숨김 처리된 거래가 없습니다.

+ ) : ( +
+ + + + No. + 사용일시 + 카드사 + 카드번호 + 카드명 + 사업자번호 + 가맹점명 + 합계금액 + 숨김일시 + 복원 + + + + {hiddenData.map((item, index) => ( + + {index + 1} + {item.usedAt} + +
+ + {item.cardCompany} +
+
+ {item.card} + {item.cardName} + {item.businessNumber} + {item.merchantName} + {item.totalAmount.toLocaleString()} + {item.hiddenAt || '-'} + + + +
+ ))} +
+
+
+ )} +
+ ) : undefined, + + // 다이얼로그 (모달) + renderDialogs: () => ( + <> + + + ), }), [ filteredData, - cardOptions, + pagination, + summary, cardFilter, + cardOptions, sortOption, startDate, endDate, - summary, - totalAmount, - selectedAccountSubject, isLoading, - router, - handleRowClick, - handleRefresh, - handleSaveAccountSubject, - getUsageTypeLabel, + isSaving, + inlineEdits, + showHiddenSection, + hiddenData, + showManualInput, + showJournalEntry, + journalTransaction, + handleSave, + handleExcelDownload, + handleHide, + handleJournalEntry, + handleUnhide, + handleInlineEdit, + getEditValue, + loadData, ] ); return ( - <> - - - {/* 계정과목명 저장 확인 다이얼로그 */} - - - - 계정과목명 변경 - - {selectedItems.size}개의 카드 사용 내역을{' '} - - {ACCOUNT_SUBJECT_OPTIONS.find((o) => o.value === selectedAccountSubject)?.label} - - (으)로 모두 변경하시겠습니까? - - - - - - - - - - {/* 선택 필요 알림 다이얼로그 */} - - - - 항목 선택 필요 - - 변경할 카드 사용 내역을 먼저 선택해주세요. - - - - setShowSelectWarningDialog(false)}> - 확인 - - - - - - {/* 카드 내역 상세 모달 */} - - - - 카드 내역 상세 - 카드 사용 상세 내역을 등록합니다 - - - {selectedItem && ( -
-
-

기본 정보

-
-
- -

{selectedItem.usedAt}

-
-
- -

- {selectedItem.card} ({selectedItem.cardName}) -

-
-
- -

{selectedItem.user}

-
-
- -

- {selectedItem.amount.toLocaleString()}원 -

-
-
- - - setDetailFormData((prev) => ({ ...prev, memo: e.target.value })) - } - placeholder="적요" - className="mt-1" - /> -
-
- -

{selectedItem.merchantName}

-
-
- - -
-
-
-
- )} - - - - -
-
- + ); -} \ No newline at end of file +} diff --git a/src/components/accounting/CardTransactionInquiry/types.ts b/src/components/accounting/CardTransactionInquiry/types.ts index 0e76fab5..515483fb 100644 --- a/src/components/accounting/CardTransactionInquiry/types.ts +++ b/src/components/accounting/CardTransactionInquiry/types.ts @@ -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: '기타' }, -]; \ No newline at end of file +]; + +// ===== 월 프리셋 옵션 ===== +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 }, +]; diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index 5a2a8253..bab963e0 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -551,7 +551,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan (으)로 모두 변경하시겠습니까? - + diff --git a/src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx b/src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx new file mode 100644 index 00000000..7ef27b9b --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/AccountSubjectSettingModal.tsx @@ -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('asset'); + const [isAdding, setIsAdding] = useState(false); + + // 검색/필터 + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + + // 데이터 + const [subjects, setSubjects] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // 삭제 확인 + const [deleteTarget, setDeleteTarget] = useState(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 ( + <> + + + + 계정과목 설정 + 계정과목을 추가, 검색, 상태변경, 삭제합니다 + + + {/* 추가 영역 */} +
+ + +
+ + +
+ +
+ + {/* 검색/필터 영역 */} +
+ setSearch(e.target.value)} + className="max-w-[250px] h-9 text-sm" + /> + + + {filteredSubjects.length}개 + +
+ + {/* 테이블 */} +
+ {isLoading ? ( +
+ 로딩 중... +
+ ) : ( + + + + 코드 + 계정과목명 + 분류 + 상태 + 작업 + + + + {filteredSubjects.length === 0 ? ( + + + 계정과목이 없습니다. + + + ) : ( + filteredSubjects.map((subject) => ( + + {subject.code} + {subject.name} + + + {ACCOUNT_CATEGORY_LABELS[subject.category]} + + + + + + + + + + )) + )} + +
+ )} +
+ + + + +
+
+ + {/* 삭제 확인 */} + !v && setDeleteTarget(null)}> + + + 계정과목 삭제 + + "{deleteTarget?.name}" 계정과목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. + + + + 취소 + + 삭제 + + + + + + ); +} diff --git a/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx new file mode 100644 index 00000000..b3a9fa5a --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx @@ -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([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([]); + const [vendors, setVendors] = useState([]); + + // 데이터 로드 + 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 ( + <> + + + + 분개 수정 + 전표의 분개 내역을 수정합니다 + + + {/* 거래 정보 (읽기전용) */} +
+
+ +
{record.date}
+
+
+ +
+ {JOURNAL_DIVISION_LABELS[record.division] || record.division} +
+
+
+ +
{record.amount.toLocaleString()}원
+
+
+ +
{record.description || '-'}
+
+
+ +
+ {bankName && accountNumber ? `${bankName} ${accountNumber}` : '-'} +
+
+
+ + {/* 전표 적요 */} + + + {/* 분개 내역 헤더 */} +
+ + +
+ + {/* 분개 테이블 */} +
+ {isLoading ? ( +
+ 로딩 중... +
+ ) : ( + + + + 구분 + 계정과목 + 거래처 + 차변 금액 + 대변 금액 + 적요 + + + + + {rows.map((row) => ( + + +
+ {JOURNAL_SIDE_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + + + + + handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0) + } + disabled={row.side === 'credit'} + className="h-8 text-sm text-right" + placeholder="0" + /> + + + + handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0) + } + disabled={row.side === 'debit'} + className="h-8 text-sm text-right" + placeholder="0" + /> + + + handleRowChange(row.id, 'memo', e.target.value)} + className="h-8 text-sm" + placeholder="적요" + /> + + + + +
+ ))} +
+ + + + 합계 + + + {totals.debitTotal.toLocaleString()} + + + {totals.creditTotal.toLocaleString()} + + + {totals.isBalanced ? ( + 대차 균형 + ) : ( + + 차이: {totals.difference.toLocaleString()}원 + + )} + + + +
+ )} +
+ + + + + + +
+
+ + {/* 분개 삭제 확인 */} + + + + 분개 삭제 + + 이 전표의 분개 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. + + + + 취소 + + 삭제 + + + + + + ); +} diff --git a/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx new file mode 100644 index 00000000..92375363 --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx @@ -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([createEmptyRow()]); + + // 옵션 데이터 + const [accountSubjects, setAccountSubjects] = useState([]); + const [vendors, setVendors] = useState([]); + 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 ( + + + + 수기 전표 입력 + 수기 전표를 입력하고 분개 내역을 등록합니다 + + + {/* 거래 정보 */} +
+
+ + +
+ {}} + disabled + /> + +
+ + {/* 분개 내역 헤더 */} +
+ + +
+ + {/* 분개 테이블 */} +
+ + + + 구분 + 계정과목 + 거래처 + 차변 금액 + 대변 금액 + 적요 + + + + + {rows.map((row) => ( + + +
+ {JOURNAL_SIDE_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + + + + + handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0) + } + disabled={row.side === 'credit'} + className="h-8 text-sm text-right" + placeholder="0" + /> + + + + handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0) + } + disabled={row.side === 'debit'} + className="h-8 text-sm text-right" + placeholder="0" + /> + + + handleRowChange(row.id, 'memo', e.target.value)} + className="h-8 text-sm" + placeholder="적요" + /> + + + + +
+ ))} +
+ + + + 합계 + + + {totals.debitTotal.toLocaleString()} + + + {totals.creditTotal.toLocaleString()} + + + + +
+
+ + {/* 차대변 불일치 경고 */} + {totals.debitTotal !== totals.creditTotal && totals.debitTotal > 0 && ( +

+ 차변 합계({totals.debitTotal.toLocaleString()})와 대변 합계( + {totals.creditTotal.toLocaleString()})가 일치하지 않습니다. +

+ )} + + + + + +
+
+ ); +} diff --git a/src/components/accounting/GeneralJournalEntry/actions.ts b/src/components/accounting/GeneralJournalEntry/actions.ts new file mode 100644 index 00000000..af6aac60 --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/actions.ts @@ -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({ + 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> { + 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 { + 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> { + 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 { + 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 { + return executeServerAction({ + url: buildApiUrl(`/api/v1/account-subjects/${id}/status`), + method: 'PATCH', + body: { is_active: isActive }, + errorMessage: '계정과목 상태 변경에 실패했습니다.', + }); +} + +// ===== 계정과목 삭제 ===== +export async function deleteAccountSubject(id: string): Promise { + 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> { + const result = await executeServerAction({ + 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 { + 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 { + return executeServerAction({ + url: buildApiUrl(`/api/v1/general-journal-entries/${id}/journal`), + method: 'DELETE', + errorMessage: '분개 삭제에 실패했습니다.', + }); +} + +// ===== 거래처 목록 조회 ===== +export async function getVendorList(): Promise> { + 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; +} diff --git a/src/components/accounting/GeneralJournalEntry/index.tsx b/src/components/accounting/GeneralJournalEntry/index.tsx new file mode 100644 index 00000000..671e587f --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/index.tsx @@ -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('this_month'); + const [startDate, setStartDate] = useState(defaultPeriod.start); + const [endDate, setEndDate] = useState(defaultPeriod.end); + const [searchKeyword, setSearchKeyword] = useState(''); + + // ===== 데이터 상태 ===== + const [journalData, setJournalData] = useState([]); + 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({ + 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(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 = 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: ( + <> + + {PERIOD_BUTTONS.map((p) => ( + + ))} + + +
+ + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="max-w-[200px] h-9 text-sm pl-8" + /> +
+ + + ), + }, + + // ===== 통계 카드 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: () => ( +
+ + +
+ ), + + // ===== 테이블 행 렌더링 (기획서 기준 10컬럼) ===== + renderTableRow: ( + item: GeneralJournalRecord, + _index: number, + _globalIndex: number, + _handlers: SelectionHandlers & RowClickHandlers + ) => ( + + {item.date} + + + + {item.description || '-'} + + + + {item.depositAmount ? item.depositAmount.toLocaleString() : '-'} + + + {item.withdrawalAmount ? item.withdrawalAmount.toLocaleString() : '-'} + + {item.balance.toLocaleString()} + + + {JOURNAL_DIVISION_LABELS[item.division] || item.division} + + + {item.journalDescription || '-'} + + {item.debitAmount ? item.debitAmount.toLocaleString() : '-'} + + + {item.creditAmount ? item.creditAmount.toLocaleString() : '-'} + + + + + + ), + + // ===== 모바일 카드 렌더링 ===== + renderMobileCard: ( + item: GeneralJournalRecord, + _index: number, + _globalIndex: number, + _handlers: SelectionHandlers & RowClickHandlers + ) => ( + {}} + 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: ( + <> + + 합계 + + {tableTotals.depositTotal.toLocaleString()} + + + {tableTotals.withdrawalTotal.toLocaleString()} + + + + + + {tableTotals.debitTotal.toLocaleString()} + + + {tableTotals.creditTotal.toLocaleString()} + + + + + +
+ + + 수기 전표 + + + + 연동 전표 + +
+
+
+ + ), + }), + [ + journalData, + pagination, + summary, + startDate, + endDate, + selectedPeriod, + searchKeyword, + tableTotals, + handlePeriodClick, + handleSearch, + ] + ); + + return ( + <> + + + {/* 계정과목 설정 팝업 */} + + + {/* 수기 전표 입력 팝업 */} + + + {/* 분개 수정 팝업 */} + {journalEditTarget && ( + !open && setJournalEditTarget(null)} + record={journalEditTarget} + onSuccess={handleJournalEditSuccess} + /> + )} + + ); +} diff --git a/src/components/accounting/GeneralJournalEntry/types.ts b/src/components/accounting/GeneralJournalEntry/types.ts new file mode 100644 index 00000000..43cfeb9a --- /dev/null +++ b/src/components/accounting/GeneralJournalEntry/types.ts @@ -0,0 +1,266 @@ +/** + * 일반전표입력 - 타입 및 상수 정의 + */ + +// ===== 전표 구분 ===== +export type JournalDivision = 'deposit' | 'withdrawal' | 'transfer' | 'manual'; + +export const JOURNAL_DIVISION_LABELS: Record = { + deposit: '입금', + withdrawal: '출금', + transfer: '대체', + manual: '수기', +}; + +// ===== 전표 소스 (수기/연동) ===== +export type JournalSource = 'manual' | 'linked'; + +export const JOURNAL_SOURCE_MAP: Record = { + 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 = { + 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) }; + } +} diff --git a/src/components/accounting/GiftCertificateManagement/GiftCertificateDetail.tsx b/src/components/accounting/GiftCertificateManagement/GiftCertificateDetail.tsx new file mode 100644 index 00000000..57212403 --- /dev/null +++ b/src/components/accounting/GiftCertificateManagement/GiftCertificateDetail.tsx @@ -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( + 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 ( + + + + {/* 기본 정보 */} + + + 기본 정보 + + +
+ handleChange('serialNumber', v)} + placeholder="자동 생성" + disabled={!isNew} + /> + handleChange('name', v)} + placeholder="상품권명을 입력하세요" + /> +
+ +
+ handleChange('faceValue', v ?? 0)} + placeholder="0" + /> + handleChange('vendorId', v)} + options={[]} + selectPlaceholder="거래처 선택" + helpText="매입 거래처명 목록 (API 연동 후 사용 가능)" + /> +
+ +
+ handleChange('purchaseDate', v)} + /> + handleChange('purchasePurpose', v)} + options={PURCHASE_PURPOSE_OPTIONS} + /> +
+ +
+ handleChange('entertainmentExpense', v)} + options={ENTERTAINMENT_EXPENSE_OPTIONS} + /> + handleChange('status', v)} + options={statusDetailOptions} + /> +
+
+
+ + {/* 상품권 정보 (액면가 50만원 이상 시) */} + + + + 상품권 정보 + {showUsageInfo && *} + + {showUsageInfo ? ( +

+ + 액면가 50만원 이상일 경우 입력 필수입니다 +

+ ) : ( +

+ 액면가 50만원 이상일 경우 입력이 필요합니다 +

+ )} +
+ +
+ handleChange('usedDate', v)} + /> + handleChange('recipientName', v)} + placeholder="수령인 이름" + required={showUsageInfo} + /> +
+ +
+ handleChange('recipientOrganization', v)} + placeholder="회사명" + /> + handleChange('usageDescription', v)} + placeholder="내용" + /> +
+ + handleChange('memo', v)} + placeholder="비고 사항을 입력하세요" + rows={3} + /> +
+
+ + {/* 하단 버튼 */} +
+ {!isNew && ( + + )} + + +
+
+ ); +} diff --git a/src/components/accounting/GiftCertificateManagement/actions.ts b/src/components/accounting/GiftCertificateManagement/actions.ts new file mode 100644 index 00000000..3e7b96d6 --- /dev/null +++ b/src/components/accounting/GiftCertificateManagement/actions.ts @@ -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> { + // TODO: 실제 API 연동 시 교체 + // return executePaginatedAction({ + // url: buildApiUrl('/api/v1/gift-certificates', { ... }), + // transform: transformApiToFrontend, + // errorMessage: '상품권 목록 조회에 실패했습니다.', + // }); + return { success: true, data: [] }; +} + +// ===== 상품권 상세 조회 (Mock) ===== +export async function getGiftCertificateById( + _id: string +): Promise> { + // 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> { + // 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> { + // 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 { + // 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> { + // 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, + }, + }; +} diff --git a/src/components/accounting/GiftCertificateManagement/index.tsx b/src/components/accounting/GiftCertificateManagement/index.tsx new file mode 100644 index 00000000..2b1a64c6 --- /dev/null +++ b/src/components/accounting/GiftCertificateManagement/index.tsx @@ -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([]); + 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 = 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: () => ( + + ), + + // 테이블 헤더 액션: 총 N건 + 상태 필터 + 접대비 필터 + tableHeaderActions: () => ( +
+ + 총 {data.length}건 + + + {/* 상태 필터 */} + + + {/* 접대비 필터 */} + +
+ ), + + // 클라이언트 사이드 커스텀 필터 (상태 + 접대비) + 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 + ) => { + const statusInfo = STATUS_MAP[item.status]; + + return ( + handleRowClick(item)} + > + {/* 체크박스 */} + e.stopPropagation()}> + handlers.onToggle()} + /> + + {/* 번호 */} + {globalIndex} + {/* 일련번호 */} + {item.serialNumber} + {/* 상품권명 */} + {item.name} + {/* 액면가 */} + + {formatAmount(item.faceValue)} + + {/* 구입일 */} + {item.purchaseDate} + {/* 사용일 */} + {item.usedDate || '-'} + {/* 접대비 */} + + {item.entertainmentExpense === 'applicable' ? '해당' : '-'} + + {/* 상태 */} + + + {statusInfo.label} + + + + ); + }, + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: GiftCertificateRecord, + _index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const statusInfo = STATUS_MAP[item.status]; + + return ( + handleRowClick(item)} + title={`${globalIndex}. ${item.name}`} + badge={{ + label: statusInfo.label, + variant: item.status === 'holding' + ? 'default' + : item.status === 'used' + ? 'secondary' + : 'destructive', + }} + infoGrid={ +
+
상품권번호 {item.serialNumber}
+
액면가 {formatAmount(item.faceValue)}원
+
구입일 {item.purchaseDate}
+
사용일 {item.usedDate || '-'}
+
접대비 {item.entertainmentExpense === 'applicable' ? '해당' : '-'}
+
+ } + /> + ); + }, + }), + [data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate] + ); + + return ( + + ); +} diff --git a/src/components/accounting/GiftCertificateManagement/types.ts b/src/components/accounting/GiftCertificateManagement/types.ts new file mode 100644 index 00000000..40506d2f --- /dev/null +++ b/src/components/accounting/GiftCertificateManagement/types.ts @@ -0,0 +1,106 @@ +/** + * 상품권 관리 - 타입 및 상수 정의 + */ + +// ===== 상품권 상태 ===== +export type GiftCertificateStatus = 'holding' | 'used' | 'disposed'; + +export const STATUS_MAP: Record = { + 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; diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index 974b1695..92499eee 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -534,7 +534,7 @@ export function PurchaseManagement() { (으)로 모두 변경하시겠습니까? - + diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index 3f4e7c6a..f3176cba 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -588,7 +588,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem (으)로 모두 변경하시겠습니까? - + diff --git a/src/components/accounting/TaxInvoiceIssuance/SupplierSettingModal.tsx b/src/components/accounting/TaxInvoiceIssuance/SupplierSettingModal.tsx new file mode 100644 index 00000000..dcf28e6d --- /dev/null +++ b/src/components/accounting/TaxInvoiceIssuance/SupplierSettingModal.tsx @@ -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(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 ( + + + + + } + > +
+ handleChange('businessNumber', v)} + placeholder="000-00-00000" + /> + handleChange('companyName', v)} + placeholder="상호명을 입력하세요" + /> + handleChange('representativeName', v)} + placeholder="대표자명을 입력하세요" + /> + handleChange('address', v)} + placeholder="사업장 주소를 입력하세요" + /> +
+ handleChange('businessType', v)} + placeholder="업태" + /> + handleChange('businessItem', v)} + placeholder="종목" + /> +
+ handleChange('contactName', v)} + placeholder="담당자명" + /> + handleChange('contactPhone', v)} + placeholder="010-0000-0000" + /> + handleChange('contactEmail', v)} + placeholder="email@example.com" + /> +
+
+ ); +} diff --git a/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceDetail.tsx b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceDetail.tsx new file mode 100644 index 00000000..c0031935 --- /dev/null +++ b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceDetail.tsx @@ -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) => ( + + + {label} + + +
+ 사업자번호 + {entity.businessNumber || '-'} + 상호 + {entity.companyName || '-'} + 대표자 + {entity.representativeName || '-'} + 주소 + {entity.address || '-'} + 업태 + {entity.businessType || '-'} + 종목 + {entity.businessItem || '-'} +
+
+
+ ); + + if (!initialData) { + return ( + + + + + 세금계산서를 찾을 수 없습니다. (ID: {id}) + + +
+ +
+
+ ); + } + + 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 ( + + + + {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ 발행번호 + {initialData.invoiceNumber} +
+
+ 작성일자 + {initialData.writeDate} +
+
+ 상태 + + {statusInfo.label} + +
+
+ 합계금액 + {totalAmount.toLocaleString('ko-KR')}원 +
+
+
+
+ + {/* 공급자 / 공급받는자 */} +
+ {renderEntityInfo(initialData.supplier, '공급자')} + {renderEntityInfo(initialData.receiver, '공급받는자')} +
+ + {/* 품목 테이블 */} + + + 품목 내역 + + +
+ + + + No. + + + 품목 + 규격 + 수량 + 단가 + 공급가액 + 세액 + 합계 + + + + {initialData.items.map((item, index) => ( + + {index + 1} + {item.month || '-'} + {item.day || '-'} + {item.itemName || '-'} + {item.specification || '-'} + {item.quantity.toLocaleString('ko-KR')} + {item.unitPrice.toLocaleString('ko-KR')} + {item.supplyAmount.toLocaleString('ko-KR')} + {item.taxAmount.toLocaleString('ko-KR')} + {item.totalAmount.toLocaleString('ko-KR')} + + ))} + + 합계 + {totalSupply.toLocaleString('ko-KR')} + {totalTax.toLocaleString('ko-KR')} + {totalAmount.toLocaleString('ko-KR')} + + +
+
+
+
+ + {/* 비고 */} + {initialData.memo && ( + + + 비고 + + +

{initialData.memo}

+
+
+ )} + + {/* 하단 버튼 */} +
+ +
+
+ ); +} diff --git a/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx new file mode 100644 index 00000000..d0a6ab06 --- /dev/null +++ b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceForm.tsx @@ -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; + 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([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 ( + + + {/* 공급자 / 공급받는자 테이블 */} +
+ + + {/* 공급자 */} + + + + + + {/* 공급받는자 */} + + + + + + + + {/* Row 1: 등록번호 / 종사업장 */} + + + + + + + + + + + + + + + {/* Row 2: 상호 / 대표자 */} + + + + + + + + + + + + + {/* Row 3: 사업장주소 */} + + + + + + + + + {/* Row 4: 업태 / 종목 */} + + + + + + + + + + + + + {/* Row 5: 담당자 / 연락처 */} + + + + + + + + + + + + + {/* Row 6: 이메일 */} + + + + + + + + +
+
공급자
+
등록번호{supplier.businessNumber || '-'}종사업장 +
공급받는자
+
등록번호 +
+ {receiver.businessNumber || ''} + +
+
종사업장
상호{supplier.companyName || ''}대표자{supplier.representativeName || ''}상호{receiver.companyName || ''}대표자{receiver.representativeName || ''}
사업장주소{supplier.address || ''}사업장주소{receiver.address || ''}
업태{supplier.businessType || ''}종목{supplier.businessItem || ''}업태{receiver.businessType || ''}종목{receiver.businessItem || ''}
담당자{supplier.contactName || ''}연락처{supplier.contactPhone || ''}담당자{receiver.contactName || ''}연락처{receiver.contactPhone || ''}
이메일{supplier.contactEmail || ''}이메일 + {receiver.contactEmail || 세금계산서 수신 이메일} +
+
+ + {/* 작성일자 */} +
+ + +
+ + {/* 품목 테이블 */} + + + {/* 비고 */} +
+ +