feat: 회계 모듈 전면 개선 — 계정과목 공통화·전표·세금계산서·어음·상품권

- AccountSubjectSelect 공통 컴포넌트 신규 (계정과목 선택 통합)
- 일반전표 수동입력/수정 모달 계정과목 연동
- 세금계산서 관리 타입 시스템 재정의 + 전표 연동 모달
- 어음관리 리팩토링 + 상품권 접대비 연동
- 카드거래 조회 전표 연동 모달 개선
- 악성채권/입출금/매입매출/거래처 상세 뷰 보강
This commit is contained in:
2026-03-10 11:35:26 +09:00
parent 72cf5d86a2
commit 81373281ea
40 changed files with 863 additions and 531 deletions

View File

@@ -17,7 +17,7 @@ export default function VendorsPage() {
useEffect(() => {
if (mode !== 'new') {
getClients({ size: 100 })
getClients({ size: 1000 })
.then(result => {
setData(result.data);
setTotal(result.total);

View File

@@ -6,6 +6,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
@@ -137,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
if (isNewMode) {
const result = await createBadDebt(formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '등록에 실패했습니다.' };
} else {
const result = await updateBadDebt(recordId!, formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '수정에 실패했습니다.' };
@@ -159,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
try {
const result = await deleteBadDebt(String(id));
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };

View File

@@ -14,6 +14,7 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -176,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
deleteItem: async (id: string) => {
const result = await deleteBadDebt(id);
if (result.success) {
invalidateDashboard('badDebt');
setData((prev) => prev.filter((item) => item.id !== id));
}
return { success: result.success, error: result.error };

View File

@@ -9,6 +9,7 @@ import { apiDataToFormData, transformFormDataToApi } from './types';
import type { BillApiData } from './types';
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
import { useBillForm } from './hooks/useBillForm';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useBillConditions } from './hooks/useBillConditions';
import {
BasicInfoSection,
@@ -130,6 +131,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
if (isNewMode) {
const result = await createBillRaw(apiPayload);
if (result.success) {
invalidateDashboard('bill');
toast.success('등록되었습니다.');
router.push('/ko/accounting/bills');
return { success: false, error: '' };
@@ -137,6 +139,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
return result;
} else {
const result = await updateBillRaw(String(billId), apiPayload);
if (result.success) {
invalidateDashboard('bill');
}
return result;
}
} finally {

View File

@@ -22,8 +22,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -89,24 +88,6 @@ export function BillManagementClient({
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// 삭제 다이얼로그
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '어음',
});
// 날짜 범위 상태
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
@@ -304,6 +285,7 @@ export function BillManagementClient({
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건이 저장되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());
@@ -334,6 +316,25 @@ export function BillManagementClient({
totalCount: pagination.total,
};
},
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return { success: result.success, error: result.error };
},
},
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
// 테이블 컬럼
@@ -445,6 +446,7 @@ export function BillManagementClient({
isLoading,
router,
loadData,
currentPage,
handleSave,
renderTableRow,
renderMobileCard,
@@ -471,14 +473,6 @@ export function BillManagementClient({
}}
/>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}

View File

@@ -158,7 +158,7 @@ export async function updateBillRaw(id: string, data: Record<string, unknown>):
// ===== 거래처 목록 조회 =====
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { formatNumber } from '@/lib/utils/amount';
import { getBills, deleteBill, updateBillStatus } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import { extractUniqueOptions } from '../shared';
import {
@@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
await loadBills();
}
@@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
// 서버에서 재조회 (pagination 메타데이터 포함)
await loadBills();
}

View File

@@ -23,7 +23,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { CardTransaction, JournalEntryItem } from './types';
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
import { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { saveJournalEntries } from './actions';
interface JournalEntryModalProps {
@@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
<div className="grid grid-cols-3 gap-3">
{/* Select - FormField 예외 */}
<div>
<Label className="text-xs"></Label>
<Select
value={item.accountSubject || 'none'}
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1 h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={item.accountSubject}
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
placeholder="선택"
size="sm"
/>
</div>
</div>
{/* Select - FormField 예외 */}
<div>

View File

@@ -25,7 +25,8 @@ import {
} 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 { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { getCardList, createCardTransaction } from './actions';
import { getTodayString } from '@/lib/utils/date';
@@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
</div>
<div>
<Label className="text-sm font-medium"></Label>
<Select
value={formData.accountSubject || 'none'}
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={formData.accountSubject}
onValueChange={(v) => handleChange('accountSubject', v)}
placeholder="선택"
/>
</div>
</div>
</div>

View File

@@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
import {
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
getCardTransactionList,
getCardTransactionSummary,
@@ -89,7 +90,7 @@ const tableColumns = [
{ 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: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', 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 },
@@ -599,20 +600,13 @@ export function CardTransactionInquiry() {
</TableCell>
{/* 계정과목 (인라인 Select) */}
<TableCell onClick={(e) => e.stopPropagation()}>
<Select
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<AccountSubjectSelect
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
placeholder="선택"
size="sm"
className="min-w-[90px] w-auto"
/>
</TableCell>
{/* 분개 버튼 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateDepositData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface DepositDetailClientV2Props {
@@ -81,14 +82,17 @@ export default function DepositDetailClientV2({
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
if (result.success && mode === 'create') {
invalidateDashboard('deposit');
toast.success('등록되었습니다.');
router.push('/ko/accounting/deposits');
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
}
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
},
[mode, depositId, router]
);
@@ -98,9 +102,11 @@ export default function DepositDetailClientV2({
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
const result = await deleteDeposit(depositId);
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
}, [depositId]);
// ===== 모드 변경 핸들러 =====

View File

@@ -73,6 +73,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
extractUniqueOptions,
@@ -225,6 +226,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
deleteItem: async (id: string) => {
const result = await deleteDeposit(id);
if (result.success) {
invalidateDashboard('deposit');
toast.success('입금 내역이 삭제되었습니다.');
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await handleRefresh();

View File

@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
Receipt,
Calendar as CalendarIcon,
@@ -88,8 +89,8 @@ import { CurrencyInput } from '@/components/ui/currency-input';
import {
TRANSACTION_TYPE_FILTER_OPTIONS,
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { extractUniqueOptions } from '../shared';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
@@ -247,6 +248,7 @@ export function ExpectedExpenseManagement({
// 수정
const result = await updateExpectedExpense(editingItem.id, formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
toast.success('미지급비용이 수정되었습니다.');
setShowFormDialog(false);
@@ -258,6 +260,7 @@ export function ExpectedExpenseManagement({
// 등록
const result = await createExpectedExpense(formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => [result.data!, ...prev]);
toast.success('미지급비용이 등록되었습니다.');
setShowFormDialog(false);
@@ -278,6 +281,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpenses(selectedIds);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
@@ -492,6 +496,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpense(deleteTargetId);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
@@ -522,6 +527,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newExpectedDate }
@@ -1185,21 +1191,12 @@ export function ExpectedExpenseManagement({
<div className="space-y-2">
<Label></Label>
<Select
value={formData.accountSubject}
<AccountSubjectSelect
value={formData.accountSubject || ''}
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
>
<SelectTrigger>
<SelectValue placeholder="계정과목 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="계정과목 선택"
category="expense"
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
Table,
TableBody,
@@ -56,14 +57,12 @@ 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';
@@ -109,7 +108,6 @@ export function JournalEditModal({
const [accountNumber, setAccountNumber] = useState('');
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
// 데이터 로드
@@ -119,15 +117,11 @@ export function JournalEditModal({
const loadData = async () => {
setIsLoading(true);
try {
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
const [detailRes, 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);
}
@@ -361,24 +355,14 @@ export function JournalEditModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -42,8 +42,9 @@ import {
TableRow,
TableFooter,
} from '@/components/ui/table';
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { createManualJournal, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, VendorOption } from './types';
import { JOURNAL_SIDE_OPTIONS } from './types';
import { getTodayString } from '@/lib/utils/date';
@@ -81,7 +82,6 @@ export function ManualJournalEntryModal({
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -94,13 +94,7 @@ export function ManualJournalEntryModal({
setDescription('');
setRows([createEmptyRow()]);
Promise.all([
getAccountSubjects({ category: 'all' }),
getVendorList(),
]).then(([subjectsRes, vendorsRes]) => {
if (subjectsRes.success && subjectsRes.data) {
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
}
getVendorList().then((vendorsRes) => {
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
@@ -272,24 +266,14 @@ export function ManualJournalEntryModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -8,69 +8,14 @@ import type {
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;
@@ -91,15 +36,6 @@ export async function getJournalEntries(params: {
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;
}
@@ -119,10 +55,6 @@ export async function getJournalSummary(params: {
errorMessage: '전표 요약 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data) {
return { success: true, data: generateMockSummary() };
}
return result;
}
@@ -151,67 +83,6 @@ export async function createManualJournal(data: {
});
}
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
}): Promise<ActionResult<AccountSubject[]>> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockAccountSubjects() };
}
return result;
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 분개 상세 조회 =====
type JournalDetailData = {
id: number;
@@ -241,26 +112,6 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
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;
}
@@ -308,9 +159,5 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
errorMessage: '거래처 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockVendors() };
}
return result;
}

View File

@@ -28,7 +28,7 @@ import {
import { MobileCard } from '@/components/organisms/MobileCard';
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
import { getJournalEntries, getJournalSummary } from './actions';
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
import { AccountSubjectSettingModal } from '@/components/accounting/common';
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
import { JournalEditModal } from './JournalEditModal';
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
@@ -38,6 +38,7 @@ import {
getPeriodDates,
} from './types';
import { formatNumber } from '@/lib/utils/amount';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== 테이블 컬럼 (기획서 기준 10개) =====
const tableColumns = [
@@ -151,12 +152,14 @@ export function GeneralJournalEntry() {
const handleManualEntrySuccess = useCallback(() => {
setShowManualEntry(false);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 분개 수정 완료 =====
const handleJournalEditSuccess = useCallback(() => {
setJournalEditTarget(null);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 합계 계산 =====

View File

@@ -34,30 +34,6 @@ export const PERIOD_BUTTONS = [
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
// ===== 계정과목 분류 =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 분개 구분 (차변/대변) =====
export type JournalSide = 'debit' | 'credit';
@@ -121,25 +97,6 @@ export interface GeneralJournalSummaryApiData {
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;
@@ -216,17 +173,6 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
};
}
// ===== 계정과목 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();

View File

@@ -13,6 +13,7 @@ import {
updateGiftCertificate,
deleteGiftCertificate,
} from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
PURCHASE_PURPOSE_OPTIONS,
ENTERTAINMENT_EXPENSE_OPTIONS,
@@ -80,6 +81,7 @@ export function GiftCertificateDetail({
: await updateGiftCertificate(id!, formData);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -96,6 +98,7 @@ export function GiftCertificateDetail({
try {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success('상품권이 삭제되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -134,8 +137,8 @@ export function GiftCertificateDetail({
label="일련번호"
value={formData.serialNumber}
onChange={(v) => handleChange('serialNumber', v)}
placeholder="자동 생성"
disabled={!isNew}
placeholder="일련번호를 입력하세요"
disabled={!isEditable}
/>
<FormField
label="상품권명"

View File

@@ -126,6 +126,8 @@ export async function getGiftCertificateSummary(params?: {
holding_amount?: number;
used_count?: number;
used_amount?: number;
entertainment_count?: number;
entertainment_amount?: number;
}) => ({
totalCount: data.total_count ?? 0,
totalAmount: data.total_amount ?? 0,
@@ -133,8 +135,8 @@ export async function getGiftCertificateSummary(params?: {
holdingAmount: data.holding_amount ?? 0,
usedCount: data.used_count ?? 0,
usedAmount: data.used_amount ?? 0,
entertainmentCount: 0,
entertainmentAmount: 0,
entertainmentCount: data.entertainment_count ?? 0,
entertainmentAmount: data.entertainment_amount ?? 0,
}),
errorMessage: '상품권 요약 조회에 실패했습니다.',
});

View File

@@ -44,8 +44,10 @@ import type {
import {
getGiftCertificates,
getGiftCertificateSummary,
deleteGiftCertificate,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { applyFilters, enumFilter } from '@/lib/utils/search';
import { useDateRange } from '@/hooks';
@@ -123,7 +125,7 @@ export function GiftCertificateManagement() {
// ===== 핸들러 =====
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
}, [router]);
const handleCreate = useCallback(() => {
@@ -145,6 +147,14 @@ export function GiftCertificateManagement() {
data,
totalCount: data.length,
}),
deleteItem: async (id: string) => {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
await loadData();
}
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
@@ -359,7 +369,7 @@ export function GiftCertificateManagement() {
);
},
}),
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
);
return (

View File

@@ -32,6 +32,7 @@ import {
deletePurchase,
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
@@ -260,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
}
if (result?.success) {
invalidateDashboard('purchase');
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
return { success: true };
} else {
@@ -282,6 +284,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const result = await deletePurchase(purchaseId);
if (result.success) {
invalidateDashboard('purchase');
toast.success('매입이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -60,6 +60,7 @@ import {
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber } from '@/lib/utils/amount';
// ===== 테이블 컬럼 정의 =====
@@ -253,6 +254,7 @@ export function PurchaseManagement() {
deleteItem: async (id: string) => {
const result = await deletePurchase(id);
if (result.success) {
invalidateDashboard('purchase');
setPurchaseData(prev => prev.filter(item => item.id !== id));
toast.success('매입이 삭제되었습니다.');
}

View File

@@ -34,6 +34,7 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
@@ -173,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
if (result?.success) {
invalidateDashboard('sales');
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
return { success: true };
} else {
@@ -195,6 +197,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const result = await deleteSale(salesId);
if (result.success) {
invalidateDashboard('sales');
toast.success('매출이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -303,7 +303,7 @@ export async function searchVendorsForTaxInvoice(
url: buildApiUrl('/api/v1/clients', {
q: query || undefined,
only_active: true,
size: 100,
size: 1000,
}),
transform: (data: { data: ClientApiData[] }) =>
data.data.map((item) => ({

View File

@@ -53,11 +53,11 @@ import {
updateJournalEntry,
deleteJournalEntry,
} from './actions';
import { AccountSubjectSelect } from '@/components/accounting/common';
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
import {
TAB_OPTIONS,
JOURNAL_SIDE_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
interface JournalEntryModalProps {
@@ -288,25 +288,14 @@ export function JournalEntryModal({
</Select>
</TableCell>
<TableCell className="p-1">
<Select
<AccountSubjectSelect
value={row.accountSubject}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubject', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
(opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
placeholder="선택"
size="sm"
/>
</TableCell>
<TableCell className="p-1">
<Input

View File

@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import { FormField } from '@/components/molecules/FormField';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import {
Dialog,
DialogContent,
@@ -199,12 +200,14 @@ export function ManualEntryModal({
onChange={(value) => handleChange('vendorName', value)}
placeholder="공급자명"
/>
<FormField
label="사업자번호"
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="사업자번호"
/>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<BusinessNumberInput
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="000-00-00000"
/>
</div>
</div>
</div>

View File

@@ -8,8 +8,8 @@ import type {
TaxInvoiceMgmtApiData,
TaxInvoiceSummary,
TaxInvoiceSummaryApiData,
CardHistoryRecord,
CardHistoryApiData,
CardHistoryRecord,
ManualEntryFormData,
JournalEntryRow,
} from './types';
@@ -20,17 +20,6 @@ import {
transformSummaryApi,
} from './types';
// ===== 세금계산서 목록 Mock =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
];
// ===== 세금계산서 목록 조회 =====
export async function getTaxInvoices(params: {
division?: string;
@@ -41,45 +30,39 @@ export async function getTaxInvoices(params: {
page?: number;
perPage?: number;
}) {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
// transform: transformApiToFrontend,
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
// });
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
return {
success: true as const,
data: filtered,
error: undefined as string | undefined,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
};
// frontend 'purchase' → backend 'purchases'
const direction = params.division === 'purchase' ? 'purchases' : params.division;
return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
url: buildApiUrl('/api/v1/tax-invoices', {
direction,
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformApiToFrontend,
errorMessage: '세금계산서 목록 조회에 실패했습니다.',
});
}
// ===== 세금계산서 요약 조회 =====
export async function getTaxInvoiceSummary(_params: {
export async function getTaxInvoiceSummary(params: {
dateType?: string;
startDate?: string;
endDate?: string;
vendorSearch?: string;
}): Promise<ActionResult<TaxInvoiceSummary>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executeServerAction({ ... });
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
return {
success: true,
data: {
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
salesCount: sales.length,
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
purchaseCount: purchase.length,
},
};
return executeServerAction<TaxInvoiceSummaryApiData, TaxInvoiceSummary>({
url: buildApiUrl('/api/v1/tax-invoices/summary', {
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
}),
transform: transformSummaryApi,
errorMessage: '세금계산서 요약 조회에 실패했습니다.',
});
}
// ===== 세금계산서 수기 등록 =====
@@ -96,35 +79,24 @@ export async function createTaxInvoice(
}
// ===== 카드 내역 조회 =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
];
export async function getCardHistory(_params: {
export async function getCardHistory(params: {
startDate?: string;
endDate?: string;
search?: string;
page?: number;
perPage?: number;
}): Promise<ActionResult<CardHistoryRecord[]>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
// url: buildApiUrl('/api/v1/card-transactions/history', {
// start_date: _params.startDate,
// end_date: _params.endDate,
// search: _params.search || undefined,
// page: _params.page,
// per_page: _params.perPage,
// }),
// transform: transformCardHistoryApi,
// errorMessage: '카드 내역 조회에 실패했습니다.',
// });
return { success: true, data: MOCK_CARD_HISTORY };
}) {
return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
url: buildApiUrl('/api/v1/card-transactions', {
start_date: params.startDate,
end_date: params.endDate,
search: params.search || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformCardHistoryApi,
errorMessage: '카드 내역 조회에 실패했습니다.',
});
}
// ===== 분개 내역 조회 =====

View File

@@ -45,12 +45,14 @@ export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
};
// ===== 세금계산서 상태 =====
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed';
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
error: { label: '오류', color: 'bg-red-100 text-red-700' },
draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' },
issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' },
sent: { label: '전송완료', color: 'bg-green-100 text-green-700' },
cancelled: { label: '취소', color: 'bg-red-100 text-red-700' },
failed: { label: '실패', color: 'bg-orange-100 text-orange-700' },
};
// ===== 소스 구분 (수기/홈택스) =====
@@ -87,24 +89,25 @@ export interface TaxInvoiceMgmtRecord {
memo: string;
}
// ===== API 응답 타입 (snake_case) =====
// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) =====
export interface TaxInvoiceMgmtApiData {
id: number;
division: string;
write_date: string;
direction: string;
supplier_corp_num: string | null;
supplier_corp_name: string | null;
buyer_corp_num: string | null;
buyer_corp_name: string | null;
issue_date: string | null;
vendor_name: string;
vendor_business_number: string;
tax_type: string;
item_name: string;
supply_amount: string | number;
tax_amount: string | number;
total_amount: string | number;
receipt_type: string;
document_number: string;
status: string;
source: string;
memo: string | null;
invoice_type: string | null;
issue_type: string | null;
nts_confirm_num: string | null;
description: string | null;
barobill_invoice_id: string | null;
items: Array<{ name?: string; [key: string]: unknown }> | null;
created_at: string;
updated_at: string;
}
@@ -121,15 +124,20 @@ export interface TaxInvoiceSummary {
purchaseCount: number;
}
// 백엔드 summary API는 by_direction 중첩 구조로 응답
interface DirectionSummary {
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}
export interface TaxInvoiceSummaryApiData {
sales_supply_amount: number;
sales_tax_amount: number;
sales_total_amount: number;
sales_count: number;
purchase_supply_amount: number;
purchase_tax_amount: number;
purchase_total_amount: number;
purchase_count: number;
by_direction: {
sales: DirectionSummary;
purchases: DirectionSummary;
};
by_status: Record<string, number>;
}
// ===== 분개 항목 =====
@@ -165,11 +173,12 @@ export interface CardHistoryRecord {
export interface CardHistoryApiData {
id: number;
transaction_date: string;
used_at: string;
merchant_name: string;
amount: string | number;
approval_number: string;
business_number: string;
approval_number?: string;
business_number?: string;
description?: string | null;
}
// ===== 수기 입력 폼 데이터 =====
@@ -202,40 +211,66 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
];
// ===== API → Frontend 변환 =====
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
const INVOICE_TYPE_TO_TAX_TYPE: Record<string, TaxType> = {
tax_invoice: 'taxable',
modified: 'taxable',
invoice: 'tax_free',
};
const ISSUE_TYPE_TO_RECEIPT_TYPE: Record<string, ReceiptType> = {
receipt: 'receipt',
claim: 'claim',
};
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
const isSales = apiData.direction === 'sales';
return {
id: String(apiData.id),
division: apiData.division as InvoiceTab,
writeDate: apiData.write_date,
division: isSales ? 'sales' : 'purchase',
writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '',
issueDate: apiData.issue_date,
vendorName: apiData.vendor_name,
vendorBusinessNumber: apiData.vendor_business_number,
taxType: apiData.tax_type as TaxType,
itemName: apiData.item_name,
supplyAmount: Number(apiData.supply_amount),
taxAmount: Number(apiData.tax_amount),
totalAmount: Number(apiData.total_amount),
receiptType: apiData.receipt_type as ReceiptType,
documentNumber: apiData.document_number,
status: apiData.status as InvoiceStatus,
source: apiData.source as InvoiceSource,
memo: apiData.memo || '',
vendorName: isSales
? (apiData.buyer_corp_name || '')
: (apiData.supplier_corp_name || ''),
vendorBusinessNumber: isSales
? (apiData.buyer_corp_num || '')
: (apiData.supplier_corp_num || ''),
taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable',
itemName: apiData.items?.[0]?.name || apiData.description || '',
supplyAmount: Number(apiData.supply_amount) || 0,
taxAmount: Number(apiData.tax_amount) || 0,
totalAmount: Number(apiData.total_amount) || 0,
receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt',
documentNumber: apiData.nts_confirm_num || '',
status: VALID_STATUSES.includes(apiData.status as InvoiceStatus)
? (apiData.status as InvoiceStatus)
: 'draft',
source: apiData.barobill_invoice_id ? 'hometax' : 'manual',
memo: apiData.description || '',
};
}
// ===== Frontend → API 변환 =====
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
const isSales = data.division === 'sales';
return {
division: data.division,
write_date: data.writeDate,
vendor_name: data.vendorName,
vendor_business_number: data.vendorBusinessNumber,
direction: isSales ? 'sales' : 'purchases',
issue_type: 'normal',
issue_date: data.writeDate,
// 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier)
// DB 컬럼이 NOT NULL이므로 빈 문자열로 전송
supplier_corp_name: isSales ? '' : data.vendorName,
supplier_corp_num: isSales ? '' : data.vendorBusinessNumber,
buyer_corp_name: isSales ? data.vendorName : '',
buyer_corp_num: isSales ? data.vendorBusinessNumber : '',
supply_amount: data.supplyAmount,
tax_amount: data.taxAmount,
total_amount: data.totalAmount,
item_name: data.itemName,
tax_type: data.taxType,
memo: data.memo || null,
invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice',
description: data.memo || null,
items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [],
};
}
@@ -243,24 +278,28 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record<string
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
return {
id: String(apiData.id),
transactionDate: apiData.transaction_date,
transactionDate: apiData.used_at,
merchantName: apiData.merchant_name,
amount: Number(apiData.amount),
approvalNumber: apiData.approval_number,
businessNumber: apiData.business_number,
approvalNumber: apiData.approval_number || '',
businessNumber: apiData.business_number || '',
};
}
// ===== 요약 API → Frontend 변환 =====
const EMPTY_DIRECTION: DirectionSummary = { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 };
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
const sales = apiData.by_direction?.sales || EMPTY_DIRECTION;
const purchases = apiData.by_direction?.purchases || EMPTY_DIRECTION;
return {
salesSupplyAmount: apiData.sales_supply_amount,
salesTaxAmount: apiData.sales_tax_amount,
salesTotalAmount: apiData.sales_total_amount,
salesCount: apiData.sales_count,
purchaseSupplyAmount: apiData.purchase_supply_amount,
purchaseTaxAmount: apiData.purchase_tax_amount,
purchaseTotalAmount: apiData.purchase_total_amount,
purchaseCount: apiData.purchase_count,
salesSupplyAmount: sales.supply_amount,
salesTaxAmount: sales.tax_amount,
salesTotalAmount: sales.total_amount,
salesCount: sales.count,
purchaseSupplyAmount: purchases.supply_amount,
purchaseTaxAmount: purchases.tax_amount,
purchaseTotalAmount: purchases.total_amount,
purchaseCount: purchases.count,
};
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { Plus, Trash2, Upload } from 'lucide-react';
@@ -194,6 +195,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '저장에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {
@@ -214,6 +216,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '삭제에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {

View File

@@ -11,6 +11,7 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { formatNumber } from '@/lib/utils/amount';
@@ -129,6 +130,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
deleteItem: async (id: string) => {
const result = await deleteClient(id);
if (result.success) {
invalidateDashboard('client');
toast.success('거래처가 삭제되었습니다.');
}
return { success: result.success, error: result.error };

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateWithdrawalData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface WithdrawalDetailClientV2Props {
@@ -82,6 +83,7 @@ export default function WithdrawalDetailClientV2({
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };
@@ -99,6 +101,7 @@ export default function WithdrawalDetailClientV2({
const result = await deleteWithdrawal(withdrawalId);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };

View File

@@ -72,9 +72,9 @@ import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actio
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
@@ -237,7 +237,15 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
totalCount: initialData.length,
};
},
deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'),
deleteItem: async (id: string) => {
const result = await deleteWithdrawal(id);
if (result.success) {
setWithdrawalData(prev => prev.filter(item => item.id !== id));
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
}
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼

View File

@@ -0,0 +1,215 @@
'use client';
/**
* 계정과목 Select 공용 컴포넌트
*
* DB 마스터에서 활성 계정과목(소분류, depth=3)을 로드하여 검색 가능한 Select로 표시.
* "[코드] 계정과목명" 형태로 표시. 코드/이름으로 검색 가능.
* Popover + Command 패턴 (SearchableSelect 기반).
* props로 category 제한 가능.
*/
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/components/ui/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { getAccountSubjects } from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import { formatAccountLabel } from './types';
interface AccountSubjectSelectProps {
value: string;
onValueChange: (value: string) => void;
/** 특정 대분류만 표시 */
category?: AccountSubjectCategory;
/** 특정 중분류만 표시 */
subCategory?: string;
/** 특정 부문만 표시 */
departmentType?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
/** 빈 값(전체) 옵션 표시 여부 */
showAllOption?: boolean;
allOptionLabel?: string;
/** 트리거 크기 */
size?: 'default' | 'sm';
/** value/onValueChange에 사용할 필드 (기본: code) */
valueField?: 'code' | 'id';
}
export function AccountSubjectSelect({
value,
onValueChange,
category,
subCategory,
departmentType,
placeholder = '계정과목 선택',
disabled = false,
className,
showAllOption = false,
allOptionLabel = '전체',
size = 'default',
valueField = 'code',
}: AccountSubjectSelectProps) {
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const triggerRef = useRef<HTMLButtonElement>(null);
const loadSubjects = useCallback(async () => {
setIsLoading(true);
try {
const result = await getAccountSubjects({
selectable: true,
isActive: true,
category: category || undefined,
subCategory: subCategory || undefined,
departmentType: departmentType || undefined,
});
if (result.success && result.data) {
setSubjects(result.data);
}
} catch {
// 조회 실패 시 빈 목록 유지
} finally {
setIsLoading(false);
}
}, [category, subCategory, departmentType]);
useEffect(() => {
loadSubjects();
}, [loadSubjects]);
// subject에서 value로 사용할 필드 추출
const getSubjectValue = useCallback(
(s: AccountSubject) => (valueField === 'id' ? s.id : s.code),
[valueField]
);
// 선택된 계정과목 찾기
const selectedSubject = useMemo(
() => subjects.find((s) => getSubjectValue(s) === value),
[subjects, value, getSubjectValue]
);
// 트리거에 표시할 텍스트
const displayLabel = useMemo(() => {
if (isLoading) return '로딩 중...';
if (value === 'all' && showAllOption) return allOptionLabel;
if (selectedSubject) return formatAccountLabel(selectedSubject);
return '';
}, [isLoading, value, showAllOption, allOptionLabel, selectedSubject]);
const handleSelect = (subjectValue: string) => {
onValueChange(subjectValue);
setOpen(false);
setSearchQuery('');
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setSearchQuery('');
}
};
const triggerClassName = size === 'sm' ? 'h-8 text-sm' : 'h-9 text-sm';
return (
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoading}
className={cn(
'w-full justify-between font-normal',
triggerClassName,
!displayLabel && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayLabel || placeholder}
</span>
{isLoading ? (
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] min-w-[280px] p-0"
align="start"
>
<Command shouldFilter>
<CommandInput
placeholder="코드 또는 계정과목명 검색..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{showAllOption && (
<CommandItem
value={allOptionLabel}
onSelect={() => handleSelect('all')}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === 'all' ? 'opacity-100' : 'opacity-0'
)}
/>
{allOptionLabel}
</CommandItem>
)}
{subjects.map((subject) => {
const subjectVal = getSubjectValue(subject);
return (
<CommandItem
key={subject.id}
value={`${subject.code} ${subject.name}`}
onSelect={() => handleSelect(subjectVal)}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === subjectVal ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-muted-foreground mr-1.5 font-mono text-xs">
{subject.code}
</span>
<span>{subject.name}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,17 +1,18 @@
'use client';
/**
*
* ()
*
* - 추가: 코드, , Select,
* - 검색: 검색 Input, Select,
* - 테이블: 코드 | | | (/ ) | ()
* - 테이블: 코드 | | | | (/ ) | ()
* -
* - 버튼: 닫기
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { Plus, Trash2, Loader2, Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -54,13 +55,16 @@ import {
createAccountSubject,
updateAccountSubjectStatus,
deleteAccountSubject,
seedDefaultAccountSubjects,
} from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
} from './types';
import type { DepartmentType } from './types';
interface AccountSubjectSettingModalProps {
open: boolean;
@@ -84,6 +88,7 @@ export function AccountSubjectSettingModal({
// 데이터
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSeeding, setIsSeeding] = useState(false);
// 삭제 확인
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
@@ -195,10 +200,40 @@ export function AccountSubjectSettingModal({
}
}, [deleteTarget, loadSubjects]);
// 기본 계정과목표 생성
const handleSeedDefaults = useCallback(async () => {
setIsSeeding(true);
try {
const result = await seedDefaultAccountSubjects();
if (result.success) {
const count = result.data?.inserted_count ?? 0;
if (count > 0) {
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
} else {
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
}
loadSubjects();
} else {
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
}
} catch {
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
} finally {
setIsSeeding(false);
}
}, [loadSubjects]);
// depth에 따른 들여쓰기
const getIndentClass = (depth: number) => {
if (depth === 1) return 'font-bold';
if (depth === 2) return 'pl-4 font-medium';
return 'pl-8';
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> , , , </DialogDescription>
@@ -211,7 +246,7 @@ export function AccountSubjectSettingModal({
label="코드"
value={newCode}
onChange={setNewCode}
placeholder="코드"
placeholder="예: 10100"
/>
<FormField
label="계정과목명"
@@ -273,9 +308,23 @@ export function AccountSubjectSettingModal({
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground ml-auto">
{filteredSubjects.length}
<span className="text-sm text-muted-foreground">
{filteredSubjects.length}
</span>
<Button
variant="outline"
size="sm"
className="h-9 ml-auto"
onClick={handleSeedDefaults}
disabled={isSeeding}
>
{isSeeding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Database className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</div>
@@ -289,30 +338,36 @@ export function AccountSubjectSettingModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubjects.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
.
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
. &quot; &quot; .
</TableCell>
</TableRow>
) : (
filteredSubjects.map((subject) => (
<TableRow key={subject.id}>
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
<TableCell className="text-sm">{subject.name}</TableCell>
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
{subject.name}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{ACCOUNT_CATEGORY_LABELS[subject.category]}
</Badge>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
</TableCell>
<TableCell className="text-center">
<Button
variant={subject.isActive ? 'default' : 'outline'}

View File

@@ -0,0 +1,123 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { AccountSubject, AccountSubjectApiData } from './types';
import { transformAccountSubjectApi } from './types';
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
subCategory?: string;
departmentType?: string;
depth?: number;
isActive?: boolean;
selectable?: boolean;
}): Promise<ActionResult<AccountSubject[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
sub_category: params?.subCategory || undefined,
department_type: params?.departmentType || undefined,
depth: params?.depth,
is_active: params?.isActive,
selectable: params?.selectable,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
sub_category: data.subCategory || undefined,
parent_code: data.parentCode || undefined,
depth: data.depth ?? 3,
department_type: data.departmentType || 'common',
description: data.description || undefined,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 수정 =====
export async function updateAccountSubject(
id: string,
data: {
name?: string;
category?: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'PUT',
body: {
name: data.name,
category: data.category,
sub_category: data.subCategory,
parent_code: data.parentCode,
depth: data.depth,
department_type: data.departmentType,
description: data.description,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 수정에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 기본 계정과목표 일괄 생성 =====
export async function seedDefaultAccountSubjects(): Promise<ActionResult<{ inserted_count: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects/seed-defaults'),
method: 'POST',
errorMessage: '기본 계정과목 생성에 실패했습니다.',
});
}

View File

@@ -0,0 +1,18 @@
export { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
export { AccountSubjectSelect } from './AccountSubjectSelect';
export type {
AccountSubject,
AccountSubjectApiData,
AccountSubjectCategory,
AccountSubCategory,
DepartmentType,
} from './types';
export {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
SUB_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
transformAccountSubjectApi,
formatAccountLabel,
} from './types';

View File

@@ -0,0 +1,118 @@
/**
* 계정과목 공용 타입 및 상수
*
* 모든 회계 모듈에서 공유하는 계정과목 관련 타입/상수 정의.
* 기존 각 모듈별 ACCOUNT_SUBJECT_OPTIONS, AccountSubjectCategory 등을 대체.
*/
// ===== 계정과목 분류 (대분류) =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 중분류 =====
export type AccountSubCategory =
| 'current_asset'
| 'fixed_asset'
| 'current_liability'
| 'long_term_liability'
| 'capital'
| 'sales_revenue'
| 'other_revenue'
| 'cogs'
| 'selling_admin'
| 'other_expense';
export const SUB_CATEGORY_LABELS: Record<AccountSubCategory, string> = {
current_asset: '유동자산',
fixed_asset: '비유동자산',
current_liability: '유동부채',
long_term_liability: '비유동부채',
capital: '자본',
sales_revenue: '매출',
other_revenue: '영업외수익',
cogs: '매출원가',
selling_admin: '판매비와관리비',
other_expense: '영업외비용',
};
// ===== 부문 =====
export type DepartmentType = 'common' | 'manufacturing' | 'admin';
export const DEPARTMENT_TYPE_LABELS: Record<DepartmentType, string> = {
common: '공통',
manufacturing: '제조',
admin: '관리',
};
// ===== 계정과목 인터페이스 =====
export interface AccountSubject {
id: string;
code: string;
name: string;
category: AccountSubjectCategory;
subCategory: string | null;
parentCode: string | null;
depth: number;
departmentType: DepartmentType;
description: string | null;
sortOrder: number;
isActive: boolean;
}
export interface AccountSubjectApiData {
id: number;
code: string;
name: string;
category: string;
sub_category: string | null;
parent_code: string | null;
depth: number;
department_type: string;
description: string | null;
sort_order: number;
is_active: boolean | number;
created_at: string;
updated_at: string;
}
// ===== API → Frontend 변환 =====
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
return {
id: String(apiData.id),
code: apiData.code,
name: apiData.name,
category: apiData.category as AccountSubjectCategory,
subCategory: apiData.sub_category,
parentCode: apiData.parent_code,
depth: apiData.depth ?? 3,
departmentType: (apiData.department_type || 'common') as DepartmentType,
description: apiData.description,
sortOrder: apiData.sort_order ?? 0,
isActive: Boolean(apiData.is_active),
};
}
// ===== 표시용 포맷 =====
export function formatAccountLabel(subject: AccountSubject): string {
return `[${subject.code}] ${subject.name}`;
}

View File

@@ -248,12 +248,14 @@ export function generatePurchaseApprovalData(options: GeneratePurchaseApprovalDa
const { vendors = SAMPLE_VENDORS, documentType = 'proposal' } = options;
const vendor = randomPick(vendors);
// 현재 사용자를 결재선에 추가 (기본값: 홍길동)
// 현재 사용자를 결재선에 추가 (기본값: 로그인 사용자 정보)
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
const userData = userDataStr ? JSON.parse(userDataStr) : null;
const currentUser: ApprovalPerson = options.currentUser || {
id: 'user-1',
department: '개발팀',
position: '사원',
name: '홍길동',
id: userData?.id || 'user-1',
department: userData?.department || '',
position: userData?.position || '',
name: userData?.name || '',
};
// 경리/회계/재무 직원 중 랜덤으로 1명 참조 추가