diff --git a/src/app/[locale]/(protected)/accounting/bills/page.tsx b/src/app/[locale]/(protected)/accounting/bills/page.tsx index e20303e4..052cd949 100644 --- a/src/app/[locale]/(protected)/accounting/bills/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bills/page.tsx @@ -1,12 +1,113 @@ -'use client'; +import { cookies } from 'next/headers'; +import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient'; +import type { BillRecord, BillApiData } from '@/components/accounting/BillManagement/types'; +import { transformApiToFrontend } from '@/components/accounting/BillManagement/types'; -import { useSearchParams } from 'next/navigation'; -import { BillManagement } from '@/components/accounting/BillManagement'; - -export default function BillsPage() { - const searchParams = useSearchParams(); - const vendorId = searchParams.get('vendorId') || undefined; - const billType = searchParams.get('type') || undefined; - - return ; +interface BillsPageProps { + searchParams: Promise<{ + vendorId?: string; + type?: string; + page?: string; + }>; +} + +async function getApiHeaders(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get('access_token')?.value; + + return { + 'Accept': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + 'X-API-KEY': process.env.API_KEY || '', + }; +} + +async function getBills(params: { + billType?: string; + page?: number; +}): Promise<{ + data: BillRecord[]; + pagination: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; +}> { + try { + const headers = await getApiHeaders(); + const queryParams = new URLSearchParams(); + + if (params.billType && params.billType !== 'all') { + queryParams.append('bill_type', params.billType); + } + if (params.page) { + queryParams.append('page', String(params.page)); + } + queryParams.append('per_page', '20'); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bills?${queryParams.toString()}`, + { method: 'GET', headers, cache: 'no-store' } + ); + + if (!response.ok) { + console.error('[BillsPage] Fetch error:', response.status); + return { + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + }; + } + + const paginatedData = result.data as { + data: BillApiData[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + + return { + data: paginatedData.data.map(transformApiToFrontend), + pagination: { + currentPage: paginatedData.current_page, + lastPage: paginatedData.last_page, + perPage: paginatedData.per_page, + total: paginatedData.total, + }, + }; + } catch (error) { + console.error('[BillsPage] Fetch error:', error); + return { + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + }; + } +} + +export default async function BillsPage({ searchParams }: BillsPageProps) { + const params = await searchParams; + const vendorId = params.vendorId; + const billType = params.type || 'received'; + const page = params.page ? parseInt(params.page) : 1; + + const { data, pagination } = await getBills({ billType, page }); + + return ( + + ); } diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index 818cca01..b8e6a399 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -6,6 +6,7 @@ import { FileText, Plus, X, + Loader2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -38,11 +39,13 @@ import { } from '@/components/ui/table'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; -import type { BillRecord, BillType, BillStatus, InstallmentRecord, Vendor } from './types'; +import { toast } from 'sonner'; +import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types'; import { BILL_TYPE_OPTIONS, getBillStatusOptions, } from './types'; +import { getBill, createBill, updateBill, deleteBill, getClients } from './actions'; // ===== Props ===== interface BillDetailProps { @@ -50,43 +53,25 @@ interface BillDetailProps { mode: 'view' | 'edit' | 'new'; } -// ===== Mock 거래처 데이터 ===== -const MOCK_VENDORS: Vendor[] = [ - { id: 'v1', name: '(주)삼성전자', email: 'samsung@example.com' }, - { id: 'v2', name: '현대자동차', email: 'hyundai@example.com' }, - { id: 'v3', name: 'LG전자', email: 'lg@example.com' }, - { id: 'v4', name: 'SK하이닉스', email: 'skhynix@example.com' }, - { id: 'v5', name: '네이버', email: 'naver@example.com' }, -]; - -// ===== Mock 상세 데이터 조회 ===== -const fetchBillDetail = (id: string): BillRecord | null => { - return { - id, - billNumber: '2025000001', - billType: 'received', - vendorId: 'v1', - vendorName: '(주)삼성전자', - amount: 100000000, - issueDate: '2025-12-12', - maturityDate: '2025-12-30', - status: 'stored', - reason: '거래 대금', - installmentCount: 2, - note: '', - installments: [ - { id: 'inst-1', date: '2025-12-20', amount: 50000000, note: '1차 상환' }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; -}; +// ===== 거래처 타입 ===== +interface ClientOption { + id: string; + name: string; +} export function BillDetail({ billId, mode }: BillDetailProps) { const router = useRouter(); const isViewMode = mode === 'view'; const isNewMode = mode === 'new'; + // ===== 로딩 상태 ===== + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // ===== 거래처 목록 ===== + const [clients, setClients] = useState([]); + // ===== 폼 상태 ===== const [billNumber, setBillNumber] = useState(''); const [billType, setBillType] = useState('received'); @@ -99,11 +84,28 @@ export function BillDetail({ billId, mode }: BillDetailProps) { const [installments, setInstallments] = useState([]); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + // ===== 거래처 목록 로드 ===== + useEffect(() => { + async function loadClients() { + const result = await getClients(); + if (result.success && result.data) { + setClients(result.data.map(c => ({ id: String(c.id), name: c.name }))); + } + } + loadClients(); + }, []); + // ===== 데이터 로드 ===== useEffect(() => { - if (billId && billId !== 'new') { - const data = fetchBillDetail(billId); - if (data) { + async function loadBill() { + if (!billId || billId === 'new') return; + + setIsLoading(true); + const result = await getBill(billId); + setIsLoading(false); + + if (result.success && result.data) { + const data = result.data; setBillNumber(data.billNumber); setBillType(data.billType); setVendorId(data.vendorId); @@ -113,30 +115,79 @@ export function BillDetail({ billId, mode }: BillDetailProps) { setStatus(data.status); setNote(data.note); setInstallments(data.installments); + } else { + toast.error(result.error || '어음 정보를 불러올 수 없습니다.'); + router.push('/ko/accounting/bills'); } } - }, [billId]); + + loadBill(); + }, [billId, router]); // ===== 저장 핸들러 ===== - const handleSave = useCallback(() => { - console.log('저장:', { - billId, + const handleSave = useCallback(async () => { + // 유효성 검사 + if (!billNumber.trim()) { + toast.error('어음번호를 입력해주세요.'); + return; + } + if (!vendorId) { + toast.error('거래처를 선택해주세요.'); + return; + } + if (amount <= 0) { + toast.error('금액을 입력해주세요.'); + return; + } + + // 차수 유효성 검사 + for (let i = 0; i < installments.length; i++) { + const inst = installments[i]; + if (!inst.date) { + toast.error(`차수 ${i + 1}번의 일자를 입력해주세요.`); + return; + } + if (inst.amount <= 0) { + toast.error(`차수 ${i + 1}번의 금액을 입력해주세요.`); + return; + } + } + + setIsSaving(true); + + const billData: Partial = { billNumber, billType, vendorId, + vendorName: clients.find(c => c.id === vendorId)?.name || '', amount, issueDate, maturityDate, status, note, installments, - }); + }; + + let result; if (isNewMode) { - router.push('/ko/accounting/bills'); + result = await createBill(billData); } else { - router.push(`/ko/accounting/bills/${billId}`); + result = await updateBill(billId, billData); } - }, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode]); + + setIsSaving(false); + + if (result.success) { + toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.'); + if (isNewMode) { + router.push('/ko/accounting/bills'); + } else { + router.push(`/ko/accounting/bills/${billId}`); + } + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } + }, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]); // ===== 취소 핸들러 ===== const handleCancel = useCallback(() => { @@ -158,10 +209,18 @@ export function BillDetail({ billId, mode }: BillDetailProps) { }, [router, billId]); // ===== 삭제 핸들러 ===== - const handleDelete = useCallback(() => { - console.log('삭제:', billId); + const handleDelete = useCallback(async () => { + setIsDeleting(true); + const result = await deleteBill(billId); + setIsDeleting(false); setShowDeleteDialog(false); - router.push('/ko/accounting/bills'); + + if (result.success) { + toast.success('어음이 삭제되었습니다.'); + router.push('/ko/accounting/bills'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } }, [billId, router]); // ===== 차수 추가 ===== @@ -190,6 +249,17 @@ export function BillDetail({ billId, mode }: BillDetailProps) { // ===== 상태 옵션 (구분에 따라 변경) ===== const statusOptions = getBillStatusOptions(billType); + // ===== 로딩 중 ===== + if (isLoading) { + return ( + +
+ +
+
+ ); + } + return ( {/* 페이지 헤더 */} @@ -219,10 +289,15 @@ export function BillDetail({ billId, mode }: BillDetailProps) { ) : ( <> - - @@ -279,9 +354,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) { - {MOCK_VENDORS.map((vendor) => ( - - {vendor.name} + {clients.map((client) => ( + + {client.name} ))} @@ -457,11 +532,13 @@ export function BillDetail({ billId, mode }: BillDetailProps) { - 취소 + 취소 + {isDeleting && } 삭제 @@ -469,4 +546,4 @@ export function BillDetail({ billId, mode }: BillDetailProps) { ); -} \ No newline at end of file +} diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx new file mode 100644 index 00000000..08dcccce --- /dev/null +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -0,0 +1,499 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + FileText, + Plus, + Pencil, + Trash2, + Save, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { TableRow, TableCell } from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { + IntegratedListTemplateV2, + type TableColumn, +} from '@/components/templates/IntegratedListTemplateV2'; +import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { toast } from 'sonner'; +import type { + BillRecord, + BillType, + BillStatus, + SortOption, +} from './types'; +import { + BILL_TYPE_LABELS, + BILL_TYPE_FILTER_OPTIONS, + BILL_STATUS_COLORS, + BILL_STATUS_FILTER_OPTIONS, + getBillStatusLabel, +} from './types'; +import { getBills, deleteBill, updateBillStatus } from './actions'; + +interface BillManagementClientProps { + initialData: BillRecord[]; + initialPagination: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + initialVendorId?: string; + initialBillType?: string; +} + +export function BillManagementClient({ + initialData, + initialPagination, + initialVendorId, + initialBillType, +}: BillManagementClientProps) { + const router = useRouter(); + + // ===== 상태 관리 ===== + const [data, setData] = useState(initialData); + const [pagination, setPagination] = useState(initialPagination); + const [isLoading, setIsLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOption, setSortOption] = useState('latest'); + const [billTypeFilter, setBillTypeFilter] = useState(initialBillType || 'received'); + const [vendorFilter, setVendorFilter] = useState(initialVendorId || 'all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [currentPage, setCurrentPage] = useState(initialPagination.currentPage); + const itemsPerPage = initialPagination.perPage; + + // 삭제 다이얼로그 + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + // 날짜 범위 상태 + const [startDate, setStartDate] = useState('2025-09-01'); + const [endDate, setEndDate] = useState('2025-09-03'); + + // ===== API 데이터 로드 ===== + const loadData = useCallback(async (page: number = 1) => { + setIsLoading(true); + try { + const result = await getBills({ + search: searchQuery || undefined, + billType: billTypeFilter !== 'all' ? billTypeFilter : undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + clientId: vendorFilter !== 'all' ? vendorFilter : undefined, + issueStartDate: startDate, + issueEndDate: endDate, + sortBy: sortOption === 'latest' || sortOption === 'oldest' ? 'issue_date' : sortOption === 'amountHigh' || sortOption === 'amountLow' ? 'amount' : 'maturity_date', + sortDir: sortOption === 'oldest' || sortOption === 'amountLow' ? 'asc' : 'desc', + perPage: itemsPerPage, + page, + }); + + if (result.success) { + setData(result.data); + setPagination(result.pagination); + setCurrentPage(result.pagination.currentPage); + } else { + toast.error(result.error || '데이터를 불러오는데 실패했습니다.'); + } + } catch { + toast.error('데이터를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]); + + // ===== 체크박스 핸들러 ===== + const toggleSelection = useCallback((id: string) => { + setSelectedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + return newSet; + }); + }, []); + + // ===== 전체 선택 핸들러 ===== + const toggleSelectAll = useCallback(() => { + if (selectedItems.size === data.length && data.length > 0) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(data.map(item => item.id))); + } + }, [selectedItems.size, data]); + + // ===== 액션 핸들러 ===== + const handleRowClick = useCallback((item: BillRecord) => { + router.push(`/ko/accounting/bills/${item.id}`); + }, [router]); + + const handleDeleteClick = useCallback((id: string) => { + setDeleteTargetId(id); + setShowDeleteDialog(true); + }, []); + + const handleConfirmDelete = useCallback(async () => { + if (deleteTargetId) { + setIsLoading(true); + const result = await deleteBill(deleteTargetId); + + if (result.success) { + setData(prev => prev.filter(item => item.id !== deleteTargetId)); + setSelectedItems(prev => { + const newSet = new Set(prev); + newSet.delete(deleteTargetId); + return newSet; + }); + toast.success('삭제되었습니다.'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + setIsLoading(false); + } + setShowDeleteDialog(false); + setDeleteTargetId(null); + }, [deleteTargetId]); + + // ===== 페이지 변경 ===== + const handlePageChange = useCallback((page: number) => { + loadData(page); + }, [loadData]); + + // ===== 테이블 컬럼 ===== + const tableColumns: TableColumn[] = useMemo(() => [ + { key: 'no', label: '번호', className: 'text-center w-[60px]' }, + { key: 'billNumber', label: '어음번호' }, + { key: 'billType', label: '구분', className: 'text-center' }, + { key: 'vendorName', label: '거래처' }, + { key: 'amount', label: '금액', className: 'text-right' }, + { key: 'issueDate', label: '발행일' }, + { key: 'maturityDate', label: '만기일' }, + { key: 'installmentCount', label: '차수', className: 'text-center' }, + { key: 'status', label: '상태', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, + ], []); + + // ===== 테이블 행 렌더링 ===== + const renderTableRow = useCallback((item: BillRecord, index: number, globalIndex: number) => { + const isSelected = selectedItems.has(item.id); + + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + toggleSelection(item.id)} /> + + {globalIndex} + {item.billNumber} + + + {BILL_TYPE_LABELS[item.billType]} + + + {item.vendorName} + {item.amount.toLocaleString()} + {item.issueDate} + {item.maturityDate} + {item.installmentCount || '-'} + + + {getBillStatusLabel(item.billType, item.status)} + + + e.stopPropagation()}> + {isSelected && ( +
+ + +
+ )} +
+
+ ); + }, [selectedItems, toggleSelection, handleRowClick, handleDeleteClick, router]); + + // ===== 모바일 카드 렌더링 ===== + const renderMobileCard = useCallback(( + item: BillRecord, + index: number, + globalIndex: number, + isSelected: boolean, + onToggle: () => void + ) => { + return ( + + + {BILL_TYPE_LABELS[item.billType]} + + + {getBillStatusLabel(item.billType, item.status)} + + + } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + +
+ } + actions={ + isSelected ? ( +
+ + +
+ ) : undefined + } + onCardClick={() => handleRowClick(item)} + /> + ); + }, [handleRowClick, handleDeleteClick, router]); + + // ===== 헤더 액션 ===== + const headerActions = ( + router.push('/ko/accounting/bills/new')}> + + 어음 등록 + + } + /> + ); + + // ===== 거래처 목록 (필터용) ===== + const vendorOptions = useMemo(() => { + const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v))]; + return [ + { value: 'all', label: '전체' }, + ...uniqueVendors.map(v => ({ value: v, label: v })) + ]; + }, [data]); + + // ===== 테이블 헤더 액션 ===== + const tableHeaderActions = ( +
+ + + + + +
+ ); + + // ===== 저장 핸들러 ===== + const handleSave = useCallback(async () => { + if (selectedItems.size === 0) { + toast.warning('선택된 항목이 없습니다.'); + return; + } + + if (statusFilter === 'all') { + toast.warning('상태를 선택해주세요.'); + return; + } + + setIsLoading(true); + let successCount = 0; + + for (const id of selectedItems) { + const result = await updateBillStatus(id, statusFilter as BillStatus); + if (result.success) { + successCount++; + } + } + + if (successCount > 0) { + toast.success(`${successCount}건이 저장되었습니다.`); + loadData(currentPage); + setSelectedItems(new Set()); + } else { + toast.error('저장에 실패했습니다.'); + } + setIsLoading(false); + }, [selectedItems, statusFilter, loadData, currentPage]); + + // ===== beforeTableContent ===== + const billStatusSelector = ( +
+ + + + + { setBillTypeFilter(value); loadData(1); }} + className="flex items-center gap-4" + > +
+ + +
+
+ + +
+
+
+ ); + + return ( + <> + item.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + pagination={{ + currentPage: pagination.currentPage, + totalPages: pagination.lastPage, + totalItems: pagination.total, + itemsPerPage: pagination.perPage, + onPageChange: handlePageChange, + }} + /> + + + + + 어음 삭제 + + 이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. + + + + 취소 + + 삭제 + + + + + + ); +} diff --git a/src/components/accounting/BillManagement/actions.ts b/src/components/accounting/BillManagement/actions.ts new file mode 100644 index 00000000..06e5883d --- /dev/null +++ b/src/components/accounting/BillManagement/actions.ts @@ -0,0 +1,367 @@ +'use server'; + +import { cookies } from 'next/headers'; +import type { BillRecord, BillApiData, BillStatus } from './types'; +import { transformApiToFrontend, transformFrontendToApi } from './types'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +async function getApiHeaders(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get('access_token')?.value; + + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + 'X-API-KEY': process.env.API_KEY || '', + }; +} + +// ===== 어음 목록 조회 ===== +export async function getBills(params: { + search?: string; + billType?: string; + status?: string; + clientId?: string; + isElectronic?: boolean; + issueStartDate?: string; + issueEndDate?: string; + maturityStartDate?: string; + maturityEndDate?: string; + sortBy?: string; + sortDir?: string; + perPage?: number; + page?: number; +}): Promise<{ + success: boolean; + data: BillRecord[]; + pagination: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + error?: string; +}> { + try { + const headers = await getApiHeaders(); + const queryParams = new URLSearchParams(); + + if (params.search) queryParams.append('search', params.search); + if (params.billType && params.billType !== 'all') queryParams.append('bill_type', params.billType); + if (params.status && params.status !== 'all') queryParams.append('status', params.status); + if (params.clientId) queryParams.append('client_id', params.clientId); + if (params.isElectronic !== undefined) queryParams.append('is_electronic', String(params.isElectronic)); + if (params.issueStartDate) queryParams.append('issue_start_date', params.issueStartDate); + if (params.issueEndDate) queryParams.append('issue_end_date', params.issueEndDate); + if (params.maturityStartDate) queryParams.append('maturity_start_date', params.maturityStartDate); + if (params.maturityEndDate) queryParams.append('maturity_end_date', params.maturityEndDate); + if (params.sortBy) queryParams.append('sort_by', params.sortBy); + if (params.sortDir) queryParams.append('sort_dir', params.sortDir); + if (params.perPage) queryParams.append('per_page', String(params.perPage)); + if (params.page) queryParams.append('page', String(params.page)); + + const response = await fetch( + `${API_URL}/api/v1/bills?${queryParams.toString()}`, + { method: 'GET', headers, cache: 'no-store' } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: result.message || 'Failed to fetch bills', + }; + } + + const paginatedData = result.data as { + data: BillApiData[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + + return { + success: true, + data: paginatedData.data.map(transformApiToFrontend), + pagination: { + currentPage: paginatedData.current_page, + lastPage: paginatedData.last_page, + perPage: paginatedData.per_page, + total: paginatedData.total, + }, + }; + } catch (error) { + console.error('[getBills] Error:', error); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: 'Server error', + }; + } +} + +// ===== 어음 상세 조회 ===== +export async function getBill(id: string): Promise<{ + success: boolean; + data?: BillRecord; + error?: string; +}> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${API_URL}/api/v1/bills/${id}`, + { method: 'GET', headers, cache: 'no-store' } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || 'Failed to fetch bill' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data as BillApiData), + }; + } catch (error) { + console.error('[getBill] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 어음 등록 ===== +export async function createBill( + data: Partial +): Promise<{ success: boolean; data?: BillRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + const apiData = transformFrontendToApi(data); + + console.log('[createBill] Sending data:', JSON.stringify(apiData, null, 2)); + + const response = await fetch( + `${API_URL}/api/v1/bills`, + { + method: 'POST', + headers, + body: JSON.stringify(apiData), + } + ); + + const result = await response.json(); + console.log('[createBill] Response:', result); + + if (!response.ok || !result.success) { + // 유효성 검사 에러 처리 + if (result.errors) { + const errorMessages = Object.values(result.errors).flat().join(', '); + return { success: false, error: errorMessages || result.message || 'Failed to create bill' }; + } + return { success: false, error: result.message || 'Failed to create bill' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data as BillApiData), + }; + } catch (error) { + console.error('[createBill] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 어음 수정 ===== +export async function updateBill( + id: string, + data: Partial +): Promise<{ success: boolean; data?: BillRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + const apiData = transformFrontendToApi(data); + + console.log('[updateBill] Sending data:', JSON.stringify(apiData, null, 2)); + + const response = await fetch( + `${API_URL}/api/v1/bills/${id}`, + { + method: 'PUT', + headers, + body: JSON.stringify(apiData), + } + ); + + const result = await response.json(); + console.log('[updateBill] Response:', result); + + if (!response.ok || !result.success) { + // 유효성 검사 에러 처리 + if (result.errors) { + const errorMessages = Object.values(result.errors).flat().join(', '); + return { success: false, error: errorMessages || result.message || 'Failed to update bill' }; + } + return { success: false, error: result.message || 'Failed to update bill' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data as BillApiData), + }; + } catch (error) { + console.error('[updateBill] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 어음 삭제 ===== +export async function deleteBill(id: string): Promise<{ success: boolean; error?: string }> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${API_URL}/api/v1/bills/${id}`, + { method: 'DELETE', headers } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || 'Failed to delete bill' }; + } + + return { success: true }; + } catch (error) { + console.error('[deleteBill] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 어음 상태 변경 ===== +export async function updateBillStatus( + id: string, + status: BillStatus +): Promise<{ success: boolean; data?: BillRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${API_URL}/api/v1/bills/${id}/status`, + { + method: 'PATCH', + headers, + body: JSON.stringify({ status }), + } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || 'Failed to update bill status' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data as BillApiData), + }; + } catch (error) { + console.error('[updateBillStatus] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 어음 요약 조회 ===== +export async function getBillSummary(params: { + billType?: string; + issueStartDate?: string; + issueEndDate?: string; + maturityStartDate?: string; + maturityEndDate?: string; +}): Promise<{ + success: boolean; + data?: { + totalAmount: number; + totalCount: number; + byType: Record; + byStatus: Record; + maturityAlertAmount: number; + }; + error?: string; +}> { + try { + const headers = await getApiHeaders(); + const queryParams = new URLSearchParams(); + + if (params.billType && params.billType !== 'all') queryParams.append('bill_type', params.billType); + if (params.issueStartDate) queryParams.append('issue_start_date', params.issueStartDate); + if (params.issueEndDate) queryParams.append('issue_end_date', params.issueEndDate); + if (params.maturityStartDate) queryParams.append('maturity_start_date', params.maturityStartDate); + if (params.maturityEndDate) queryParams.append('maturity_end_date', params.maturityEndDate); + + const response = await fetch( + `${API_URL}/api/v1/bills/summary?${queryParams.toString()}`, + { method: 'GET', headers, cache: 'no-store' } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || 'Failed to fetch summary' }; + } + + return { + success: true, + data: { + totalAmount: result.data.total_amount, + totalCount: result.data.total_count, + byType: result.data.by_type, + byStatus: result.data.by_status, + maturityAlertAmount: result.data.maturity_alert_amount, + }, + }; + } catch (error) { + console.error('[getBillSummary] Error:', error); + return { success: false, error: 'Server error' }; + } +} + +// ===== 거래처 목록 조회 ===== +export async function getClients(): Promise<{ + success: boolean; + data?: { id: number; name: string }[]; + error?: string; +}> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${API_URL}/api/v1/clients?per_page=100`, + { method: 'GET', headers, cache: 'no-store' } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || 'Failed to fetch clients' }; + } + + const clients = result.data?.data || result.data || []; + + return { + success: true, + data: clients.map((c: { id: number; name: string }) => ({ + id: c.id, + name: c.name, + })), + }; + } catch (error) { + console.error('[getClients] Error:', error); + return { success: false, error: 'Server error' }; + } +} diff --git a/src/components/accounting/BillManagement/types.ts b/src/components/accounting/BillManagement/types.ts index aa35189d..472f7585 100644 --- a/src/components/accounting/BillManagement/types.ts +++ b/src/components/accounting/BillManagement/types.ts @@ -168,4 +168,111 @@ export function getBillStatusOptions(billType: BillType) { return RECEIVED_BILL_STATUS_OPTIONS; } return ISSUED_BILL_STATUS_OPTIONS; +} + +// ===== API 응답 타입 ===== +export interface BillApiInstallment { + id: number; + bill_id: number; + installment_date: string; + amount: string; + note: string | null; + created_at: string; + updated_at: string; +} + +export interface BillApiData { + id: number; + tenant_id: number; + bill_number: string; + bill_type: BillType; + client_id: number | null; + client_name: string | null; + amount: string; + issue_date: string; + maturity_date: string; + status: BillStatus; + reason: string | null; + installment_count: number; + note: string | null; + is_electronic: boolean; + bank_account_id: number | null; + created_by: number | null; + updated_by: number | null; + created_at: string; + updated_at: string; + client?: { + id: number; + name: string; + } | null; + bank_account?: { + id: number; + bank_name: string; + account_name: string; + } | null; + installments?: BillApiInstallment[]; +} + +export interface BillApiResponse { + success: boolean; + message: string; + data: BillApiData | BillApiData[] | { + data: BillApiData[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; +} + +// ===== API → Frontend 변환 함수 ===== +export function transformApiToFrontend(apiData: BillApiData): BillRecord { + return { + id: String(apiData.id), + billNumber: apiData.bill_number, + billType: apiData.bill_type, + vendorId: apiData.client_id ? String(apiData.client_id) : '', + vendorName: apiData.client?.name || apiData.client_name || '', + amount: parseFloat(apiData.amount), + issueDate: apiData.issue_date, + maturityDate: apiData.maturity_date, + status: apiData.status, + reason: apiData.reason || '', + installmentCount: apiData.installment_count, + note: apiData.note || '', + installments: (apiData.installments || []).map((inst) => ({ + id: String(inst.id), + date: inst.installment_date, + amount: parseFloat(inst.amount), + note: inst.note || '', + })), + createdAt: apiData.created_at, + updatedAt: apiData.updated_at, + }; +} + +// ===== Frontend → API 변환 함수 ===== +export function transformFrontendToApi(data: Partial): Record { + const result: Record = {}; + + if (data.billNumber !== undefined) result.bill_number = data.billNumber; + if (data.billType !== undefined) result.bill_type = data.billType; + if (data.vendorId !== undefined) result.client_id = data.vendorId ? parseInt(data.vendorId) : null; + if (data.vendorName !== undefined) result.client_name = data.vendorName || null; + if (data.amount !== undefined) result.amount = data.amount; + if (data.issueDate !== undefined) result.issue_date = data.issueDate; + if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate; + if (data.status !== undefined) result.status = data.status; + if (data.reason !== undefined) result.reason = data.reason || null; + if (data.note !== undefined) result.note = data.note || null; + + if (data.installments !== undefined) { + result.installments = data.installments.map((inst) => ({ + date: inst.date, + amount: inst.amount, + note: inst.note || null, + })); + } + + return result; } \ No newline at end of file