diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx index 729b940c..99cff423 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx @@ -1,7 +1,29 @@ -'use client'; +/** + * 악성채권 추심관리 목록 페이지 + * + * 경로: /accounting/bad-debt-collection + * API: + * - GET /api/v1/bad-debts - 악성채권 목록 + * - GET /api/v1/bad-debts/summary - 통계 정보 + */ import { BadDebtCollection } from '@/components/accounting/BadDebtCollection'; +import { getBadDebts, getBadDebtSummary } from '@/components/accounting/BadDebtCollection/actions'; -export default function BadDebtCollectionPage() { - return ; -} \ No newline at end of file +export default async function BadDebtCollectionPage() { + // 서버에서 데이터 병렬 조회 + const [badDebts, summary] = await Promise.all([ + getBadDebts({ size: 100 }), + getBadDebtSummary(), + ]); + + console.log('[BadDebtPage] Data count:', badDebts.length); + console.log('[BadDebtPage] Summary:', summary); + + return ( + + ); +} 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/app/[locale]/(protected)/sales/pricing-management/create/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx index 60baae73..a382901b 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx @@ -1,8 +1,10 @@ /** * 단가 등록 페이지 * - * 경로: /sales/pricing-management/create?itemId=xxx&itemTypeCode=MATERIAL|PRODUCT + * 경로: /sales/pricing-management/create?itemId=xxx * API: POST /api/v1/pricing + * + * item_type_code는 품목 정보에서 자동으로 가져옴 (FG, PT, SM, RM, CS 등) */ import { PricingFormClient } from '@/components/pricing'; @@ -13,14 +15,12 @@ interface CreatePricingPageProps { searchParams: Promise<{ itemId?: string; itemCode?: string; - itemTypeCode?: 'PRODUCT' | 'MATERIAL'; // PRODUCT 또는 MATERIAL (API 등록 시 필요) }>; } export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) { const params = await searchParams; const itemId = params.itemId || ''; - const itemTypeCode = params.itemTypeCode || 'MATERIAL'; // 품목 정보 조회 const itemInfo = itemId ? await getItemInfo(itemId) : null; @@ -53,10 +53,11 @@ export default async function CreatePricingPage({ searchParams }: CreatePricingP } // 서버 액션: 단가 등록 + // item_type_code는 data.itemType에서 자동으로 가져옴 async function handleSave(data: PricingData) { 'use server'; - const result = await createPricing(data, itemTypeCode); + const result = await createPricing(data); if (!result.success) { throw new Error(result.error || '단가 등록에 실패했습니다.'); diff --git a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx index 44b027a7..84e98b8a 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx @@ -46,7 +46,7 @@ interface ItemsApiResponse { interface PriceApiItem { id: number; tenant_id: number; - item_type_code: 'PRODUCT' | 'MATERIAL'; + item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일) item_id: number; client_group_id: number | null; purchase_price: string | null; @@ -113,7 +113,7 @@ async function getApiHeaders(): Promise { return { 'Accept': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', }; } @@ -140,12 +140,6 @@ function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_re } } -// 품목 유형 → API item_type_code 매핑 (백엔드 pricing API용) -function mapItemTypeCode(itemType?: string): 'PRODUCT' | 'MATERIAL' { - // FG(제품)만 PRODUCT, 나머지는 모두 MATERIAL - return itemType === 'FG' ? 'PRODUCT' : 'MATERIAL'; -} - // ============================================ // API 호출 함수 // ============================================ @@ -225,7 +219,7 @@ async function getPricingList(): Promise { * 품목 목록 + 단가 목록 병합 * * - 품목 목록을 기준으로 순회 - * - 각 품목에 해당하는 단가 정보를 매핑 + * - 각 품목에 해당하는 단가 정보를 매핑 (item_type + item_id로 매칭) * - 단가 미등록 품목은 'not_registered' 상태로 표시 */ function mergeItemsWithPricing( @@ -233,7 +227,7 @@ function mergeItemsWithPricing( pricings: PriceApiItem[] ): PricingListItem[] { // 단가 정보를 빠르게 찾기 위한 Map 생성 - // key: "PRODUCT_123" 또는 "MATERIAL_456" + // key: "{item_type}_{item_id}" (예: "FG_123", "PT_456") const pricingMap = new Map(); for (const pricing of pricings) { @@ -267,7 +261,7 @@ function mergeItemsWithPricing( status: mapStatus(pricing.status, pricing.is_final), currentRevision: 0, isFinal: pricing.is_final, - itemTypeCode: item.item_type as 'PRODUCT' | 'MATERIAL' | undefined, // PRODUCT 또는 MATERIAL (등록 시 필요) + itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요) }; } else { // 단가 미등록 품목 @@ -287,7 +281,7 @@ function mergeItemsWithPricing( status: 'not_registered' as const, currentRevision: 0, isFinal: false, - itemTypeCode: item.item_type as 'PRODUCT' | 'MATERIAL' | undefined, // PRODUCT 또는 MATERIAL (등록 시 필요) + itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요) }; } }); diff --git a/src/components/accounting/BadDebtCollection/actions.ts b/src/components/accounting/BadDebtCollection/actions.ts new file mode 100644 index 00000000..94295aaa --- /dev/null +++ b/src/components/accounting/BadDebtCollection/actions.ts @@ -0,0 +1,541 @@ +/** + * 악성채권 추심관리 서버 액션 + * + * API Endpoints: + * - GET /api/v1/bad-debts - 목록 조회 + * - GET /api/v1/bad-debts/{id} - 상세 조회 + * - POST /api/v1/bad-debts - 등록 + * - PUT /api/v1/bad-debts/{id} - 수정 + * - DELETE /api/v1/bad-debts/{id} - 삭제 + * - PATCH /api/v1/bad-debts/{id}/toggle - 활성화 토글 + * - GET /api/v1/bad-debts/summary - 통계 조회 + */ + +'use server'; + +import { cookies } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import type { BadDebtRecord, CollectionStatus } from './types'; + +// ============================================ +// API 응답 타입 정의 +// ============================================ + +interface ApiResponse { + success: boolean; + data: T; + message: string; +} + +interface PaginatedResponse { + current_page: number; + data: T[]; + total: number; + per_page: number; + last_page: number; +} + +// API 악성채권 데이터 타입 +interface BadDebtApiData { + id: number; + tenant_id: number; + client_id: number; + debt_amount: string; + status: 'collecting' | 'legal_action' | 'recovered' | 'bad_debt'; + overdue_days: number; + occurred_at: string; + closed_at: string | null; + assigned_manager_id: number | null; + is_active: boolean; + note: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; + client?: { + id: number; + code: string; + name: string; + business_number: string | null; + representative_name: string | null; + client_type: string | null; + business_type: string | null; + business_category: string | null; + zip_code: string | null; + address1: string | null; + address2: string | null; + phone: string | null; + mobile: string | null; + fax: string | null; + email: string | null; + contact_name: string | null; + contact_phone: string | null; + }; + assigned_manager?: { + id: number; + name: string; + department?: { + id: number; + name: string; + }; + position: string | null; + phone: string | null; + }; + documents?: Array<{ + id: number; + file_name: string; + file_path: string; + file_size: number; + mime_type: string; + created_at: string; + }>; + memos?: Array<{ + id: number; + content: string; + created_at: string; + created_by: number; + created_by_user?: { + id: number; + name: string; + }; + }>; +} + +// 통계 API 응답 타입 +interface BadDebtSummaryApiData { + total_amount: number; + collecting_amount: number; + legal_action_amount: number; + recovered_amount: number; + bad_debt_amount: number; + total_count: number; + collecting_count: number; + legal_action_count: number; + recovered_count: number; + bad_debt_count: number; +} + +// ============================================ +// 헬퍼 함수 +// ============================================ + +/** + * API 헤더 생성 + */ +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 || '', + }; +} + +/** + * API 상태 → 프론트엔드 상태 변환 + * API: legal_action, bad_debt (snake_case) + * Frontend: legalAction, badDebt (camelCase) + */ +function mapApiStatusToFrontend(apiStatus: string): CollectionStatus { + switch (apiStatus) { + case 'collecting': return 'collecting'; + case 'legal_action': return 'legalAction'; + case 'recovered': return 'recovered'; + case 'bad_debt': return 'badDebt'; + default: return 'collecting'; + } +} + +/** + * 프론트엔드 상태 → API 상태 변환 + */ +function mapFrontendStatusToApi(status: CollectionStatus): string { + switch (status) { + case 'collecting': return 'collecting'; + case 'legalAction': return 'legal_action'; + case 'recovered': return 'recovered'; + case 'badDebt': return 'bad_debt'; + default: return 'collecting'; + } +} + +/** + * API client_type → 프론트엔드 vendorType 변환 + */ +function mapClientTypeToVendorType(clientType?: string | null): 'sales' | 'purchase' | 'both' { + switch (clientType) { + case 'customer': + case 'sales': + return 'sales'; + case 'supplier': + case 'purchase': + return 'purchase'; + default: + return 'both'; + } +} + +/** + * API 데이터 → 프론트엔드 타입 변환 + */ +function transformApiToFrontend(apiData: BadDebtApiData): BadDebtRecord { + const client = apiData.client; + const manager = apiData.assigned_manager; + + return { + id: String(apiData.id), + vendorId: String(apiData.client_id), + vendorCode: client?.code || '', + vendorName: client?.name || '거래처 없음', + businessNumber: client?.business_number || '', + representativeName: client?.representative_name || '', + vendorType: mapClientTypeToVendorType(client?.client_type), + businessType: client?.business_type || '', + businessCategory: client?.business_category || '', + zipCode: client?.zip_code || '', + address1: client?.address1 || '', + address2: client?.address2 || '', + phone: client?.phone || '', + mobile: client?.mobile || '', + fax: client?.fax || '', + email: client?.email || '', + contactName: client?.contact_name || '', + contactPhone: client?.contact_phone || '', + systemManager: '', + debtAmount: parseFloat(apiData.debt_amount) || 0, + status: mapApiStatusToFrontend(apiData.status), + overdueDays: apiData.overdue_days || 0, + overdueToggle: apiData.is_active, + occurrenceDate: apiData.occurred_at, + endDate: apiData.closed_at, + assignedManagerId: apiData.assigned_manager_id ? String(apiData.assigned_manager_id) : null, + assignedManager: manager ? { + id: String(manager.id), + departmentName: manager.department?.name || '', + name: manager.name, + position: manager.position || '', + phone: manager.phone || '', + } : null, + settingToggle: apiData.is_active, + files: apiData.documents?.map(doc => ({ + id: String(doc.id), + name: doc.file_name, + url: doc.file_path, + type: 'additional' as const, + })) || [], + memos: apiData.memos?.map(memo => ({ + id: String(memo.id), + content: memo.content, + createdAt: memo.created_at, + createdBy: memo.created_by_user?.name || `User ${memo.created_by}`, + })) || [], + createdAt: apiData.created_at, + updatedAt: apiData.updated_at, + }; +} + +/** + * 프론트엔드 데이터 → API 요청 형식 변환 + */ +function transformFrontendToApi(data: Partial): Record { + return { + client_id: data.vendorId ? parseInt(data.vendorId) : null, + debt_amount: data.debtAmount || 0, + status: data.status ? mapFrontendStatusToApi(data.status) : 'collecting', + overdue_days: data.overdueDays || 0, + occurred_at: data.occurrenceDate || null, + closed_at: data.endDate || null, + assigned_manager_id: data.assignedManagerId ? parseInt(data.assignedManagerId) : null, + is_active: data.settingToggle ?? true, + note: null, + }; +} + +// ============================================ +// API 호출 함수 +// ============================================ + +/** + * 악성채권 목록 조회 + */ +export async function getBadDebts(params?: { + page?: number; + size?: number; + status?: string; + client_id?: string; +}): Promise { + try { + const headers = await getApiHeaders(); + const searchParams = new URLSearchParams(); + + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.size) searchParams.set('size', String(params.size)); + if (params?.status && params.status !== 'all') { + searchParams.set('status', mapFrontendStatusToApi(params.status as CollectionStatus)); + } + if (params?.client_id && params.client_id !== 'all') { + searchParams.set('client_id', params.client_id); + } + + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts?${searchParams.toString()}`; + + const response = await fetch(url, { + method: 'GET', + headers, + cache: 'no-store', + }); + + if (!response.ok) { + console.error('[BadDebtActions] GET list error:', response.status); + return []; + } + + const result: ApiResponse> = await response.json(); + + if (!result.success || !result.data?.data) { + console.warn('[BadDebtActions] No data in response'); + return []; + } + + return result.data.data.map(transformApiToFrontend); + } catch (error) { + console.error('[BadDebtActions] getBadDebts error:', error); + return []; + } +} + +/** + * 악성채권 상세 조회 + */ +export async function getBadDebtById(id: string): Promise { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, + { + method: 'GET', + headers, + cache: 'no-store', + } + ); + + if (!response.ok) { + console.error('[BadDebtActions] GET detail error:', response.status); + return null; + } + + const result: ApiResponse = await response.json(); + + if (!result.success || !result.data) { + return null; + } + + return transformApiToFrontend(result.data); + } catch (error) { + console.error('[BadDebtActions] getBadDebtById error:', error); + return null; + } +} + +/** + * 악성채권 통계 조회 + */ +export async function getBadDebtSummary(): Promise { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/summary`, + { + method: 'GET', + headers, + cache: 'no-store', + } + ); + + if (!response.ok) { + console.error('[BadDebtActions] GET summary error:', response.status); + return null; + } + + const result: ApiResponse = await response.json(); + + if (!result.success || !result.data) { + return null; + } + + return result.data; + } catch (error) { + console.error('[BadDebtActions] getBadDebtSummary error:', error); + return null; + } +} + +/** + * 악성채권 등록 + */ +export async function createBadDebt( + data: Partial +): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + const apiData = transformFrontendToApi(data); + + console.log('[BadDebtActions] POST request:', apiData); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts`, + { + method: 'POST', + headers, + body: JSON.stringify(apiData), + } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '악성채권 등록에 실패했습니다.', + }; + } + + revalidatePath('/accounting/bad-debt-collection'); + + return { + success: true, + data: transformApiToFrontend(result.data), + }; + } catch (error) { + console.error('[BadDebtActions] createBadDebt error:', error); + return { + success: false, + error: '서버 오류가 발생했습니다.', + }; + } +} + +/** + * 악성채권 수정 + */ +export async function updateBadDebt( + id: string, + data: Partial +): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + const apiData = transformFrontendToApi(data); + + console.log('[BadDebtActions] PUT request:', apiData); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, + { + method: 'PUT', + headers, + body: JSON.stringify(apiData), + } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '악성채권 수정에 실패했습니다.', + }; + } + + revalidatePath('/accounting/bad-debt-collection'); + + return { + success: true, + data: transformApiToFrontend(result.data), + }; + } catch (error) { + console.error('[BadDebtActions] updateBadDebt error:', error); + return { + success: false, + error: '서버 오류가 발생했습니다.', + }; + } +} + +/** + * 악성채권 삭제 + */ +export async function deleteBadDebt(id: string): Promise<{ success: boolean; error?: string }> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, + { + method: 'DELETE', + headers, + } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '악성채권 삭제에 실패했습니다.', + }; + } + + revalidatePath('/accounting/bad-debt-collection'); + + return { success: true }; + } catch (error) { + console.error('[BadDebtActions] deleteBadDebt error:', error); + return { + success: false, + error: '서버 오류가 발생했습니다.', + }; + } +} + +/** + * 악성채권 활성화 토글 + */ +export async function toggleBadDebt(id: string): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { + try { + const headers = await getApiHeaders(); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}/toggle`, + { + method: 'PATCH', + headers, + } + ); + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '상태 변경에 실패했습니다.', + }; + } + + revalidatePath('/accounting/bad-debt-collection'); + + return { + success: true, + data: transformApiToFrontend(result.data), + }; + } catch (error) { + console.error('[BadDebtActions] toggleBadDebt error:', error); + return { + success: false, + error: '서버 오류가 발생했습니다.', + }; + } +} diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index a62a81db..ea4fcb24 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { format } from 'date-fns'; import { AlertTriangle, Pencil, @@ -39,7 +38,6 @@ import { import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import type { BadDebtRecord, - CollectionStatus, SortOption, } from './types'; import { @@ -48,76 +46,19 @@ import { STATUS_BADGE_STYLES, SORT_OPTIONS, } from './types'; +import { deleteBadDebt, toggleBadDebt } from './actions'; -// ===== Mock 데이터 생성 ===== -const generateMockData = (): BadDebtRecord[] => { - const statuses: CollectionStatus[] = ['collecting', 'legalAction', 'recovered', 'badDebt']; - const vendors = [ - { id: 'v1', code: 'V0001', name: '(주)삼성전자', bizNum: '123-45-67890' }, - { id: 'v2', code: 'V0002', name: '현대자동차', bizNum: '234-56-78901' }, - { id: 'v3', code: 'V0003', name: 'LG전자', bizNum: '345-67-89012' }, - { id: 'v4', code: 'V0004', name: 'SK하이닉스', bizNum: '456-78-90123' }, - { id: 'v5', code: 'V0005', name: '네이버', bizNum: '567-89-01234' }, - { id: 'v6', code: 'V0006', name: '카카오', bizNum: '678-90-12345' }, - { id: 'v7', code: 'V0007', name: '쿠팡', bizNum: '789-01-23456' }, - ]; - const managers = ['홍길동', '김철수', '이영희', '박민수', '최지현']; - const amounts = [10000000, 25000000, 5000000, 30000000, 15000000, 8000000, 40000000]; - - return Array.from({ length: 20 }, (_, i) => { - const vendor = vendors[i % vendors.length]; - const occurrenceDate = new Date(2025, 10 - (i % 3), 1 + (i * 2) % 28); - const overdueDays = Math.floor((new Date().getTime() - occurrenceDate.getTime()) / (1000 * 60 * 60 * 24)); - - return { - id: `bad-debt-${i + 1}`, - vendorId: vendor.id, - vendorCode: vendor.code, - vendorName: vendor.name, - businessNumber: vendor.bizNum, - representativeName: `대표자${i + 1}`, - vendorType: 'both', - businessType: '제조업', - businessCategory: '전자제품', - zipCode: '06234', - address1: '서울특별시 서초구 서초대로 123', - address2: '대한건물 12층 1201호', - phone: '02-1234-1234', - mobile: '010-1234-1234', - fax: '02-1234-1235', - email: 'abc@email.com', - contactName: '담당자명', - contactPhone: '010-1234-1234', - systemManager: '관리자명', - debtAmount: amounts[i % amounts.length], - status: statuses[i % statuses.length], - overdueDays: overdueDays > 0 ? overdueDays : 100 + (i * 10), - overdueToggle: i % 2 === 0, - occurrenceDate: format(occurrenceDate, 'yyyy-MM-dd'), - endDate: statuses[i % statuses.length] === 'recovered' ? format(new Date(), 'yyyy-MM-dd') : null, - assignedManagerId: `manager-${i % 5}`, - assignedManager: { - id: `manager-${i % 5}`, - departmentName: '경영지원팀', - name: managers[i % managers.length], - position: '과장', - phone: '010-1234-1234', - }, - settingToggle: true, - files: [], - memos: [ - { - id: `memo-${i}-1`, - content: '2025-12-12 12:21 [홍길동] 메모 내용', - createdAt: '2025-12-12T12:21:00.000Z', - createdBy: '홍길동', - }, - ], - createdAt: '2025-12-01T00:00:00.000Z', - updatedAt: '2025-12-18T00:00:00.000Z', - }; - }); -}; +// ===== Props 타입 정의 ===== +interface BadDebtCollectionProps { + initialData: BadDebtRecord[]; + initialSummary?: { + total_amount: number; + collecting_amount: number; + legal_action_amount: number; + recovered_amount: number; + bad_debt_amount: number; + } | null; +} // 거래처 목록 추출 (필터용) const getVendorOptions = (data: BadDebtRecord[]) => { @@ -134,8 +75,9 @@ const getVendorOptions = (data: BadDebtRecord[]) => { ]; }; -export function BadDebtCollection() { +export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollectionProps) { const router = useRouter(); + const [isPending, startTransition] = useTransition(); // ===== 상태 관리 ===== const [searchQuery, setSearchQuery] = useState(''); @@ -150,8 +92,8 @@ export function BadDebtCollection() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); - // Mock 데이터 - const [data, setData] = useState(generateMockData); + // 데이터 (서버에서 받은 초기 데이터 사용) + const [data, setData] = useState(initialData); // 거래처 옵션 const vendorOptions = useMemo(() => getVendorOptions(data), [data]); @@ -229,26 +171,56 @@ export function BadDebtCollection() { const handleConfirmDelete = useCallback(() => { if (deleteTargetId) { - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; + startTransition(async () => { + const result = await deleteBadDebt(deleteTargetId); + if (result.success) { + setData(prev => prev.filter(item => item.id !== deleteTargetId)); + setSelectedItems(prev => { + const newSet = new Set(prev); + newSet.delete(deleteTargetId); + return newSet; + }); + } else { + console.error('[BadDebtCollection] Delete failed:', result.error); + } }); } setShowDeleteDialog(false); setDeleteTargetId(null); }, [deleteTargetId]); - // 설정 토글 핸들러 + // 설정 토글 핸들러 (API 호출) const handleSettingToggle = useCallback((id: string, checked: boolean) => { + // Optimistic update setData(prev => prev.map(item => item.id === id ? { ...item, settingToggle: checked } : item )); + + startTransition(async () => { + const result = await toggleBadDebt(id); + if (!result.success) { + // Rollback on error + setData(prev => prev.map(item => + item.id === id ? { ...item, settingToggle: !checked } : item + )); + console.error('[BadDebtCollection] Toggle failed:', result.error); + } + }); }, []); - // ===== 통계 카드 ===== + // ===== 통계 카드 (API 통계 또는 로컬 계산) ===== const statCards: StatCard[] = useMemo(() => { + if (initialSummary) { + // API 통계 데이터 사용 + return [ + { label: '총 악성채권', value: `${initialSummary.total_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-500' }, + { label: '추심중', value: `${initialSummary.collecting_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-orange-500' }, + { label: '법적조치', value: `${initialSummary.legal_action_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-600' }, + { label: '회수완료', value: `${initialSummary.recovered_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-green-500' }, + ]; + } + + // 로컬 데이터로 계산 (fallback) const totalAmount = data.reduce((sum, d) => sum + d.debtAmount, 0); const collectingAmount = data.filter(d => d.status === 'collecting').reduce((sum, d) => sum + d.debtAmount, 0); const legalActionAmount = data.filter(d => d.status === 'legalAction').reduce((sum, d) => sum + d.debtAmount, 0); @@ -260,7 +232,7 @@ export function BadDebtCollection() { { label: '법적조치', value: `${legalActionAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-600' }, { label: '회수완료', value: `${recoveredAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-green-500' }, ]; - }, [data]); + }, [data, initialSummary]); // ===== 테이블 컬럼 ===== const tableColumns: TableColumn[] = useMemo(() => [ @@ -313,6 +285,7 @@ export function BadDebtCollection() { handleSettingToggle(item.id, checked)} + disabled={isPending} /> {/* 작업 */} @@ -340,7 +313,7 @@ export function BadDebtCollection() { ); - }, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick, handleSettingToggle]); + }, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick, handleSettingToggle, isPending]); // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( @@ -484,12 +457,13 @@ export function BadDebtCollection() { - 삭제 + {isPending ? '삭제 중...' : '삭제'} ); -} \ No newline at end of file +} 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 diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx index 1554017c..88e2bc79 100644 --- a/src/components/pricing/PricingListClient.tsx +++ b/src/components/pricing/PricingListClient.tsx @@ -153,9 +153,8 @@ export function PricingListClient({ // 네비게이션 핸들러 const handleRegister = (item: PricingListItem) => { - // itemTypeCode를 URL 파라미터에 포함 (PRODUCT 또는 MATERIAL) - const itemTypeCode = item.itemTypeCode || 'MATERIAL'; - router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}&itemTypeCode=${itemTypeCode}`); + // item_type_code는 품목 정보에서 자동으로 가져오므로 URL에 포함하지 않음 + router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}`); }; const handleEdit = (item: PricingListItem) => { diff --git a/src/components/pricing/actions.ts b/src/components/pricing/actions.ts index 88bc7a0d..643bc100 100644 --- a/src/components/pricing/actions.ts +++ b/src/components/pricing/actions.ts @@ -27,7 +27,7 @@ interface ApiResponse { interface PriceApiData { id: number; tenant_id: number; - item_type_code: 'PRODUCT' | 'MATERIAL'; + item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일) item_id: number; client_group_id: number | null; purchase_price: string | null; @@ -152,10 +152,11 @@ function transformApiToFrontend(apiData: PriceApiData): PricingData { /** * 프론트엔드 데이터 → API 요청 형식 변환 + * item_type_code는 품목 정보(data.itemType)에서 가져옴 (FG, PT, SM, RM, CS 등) */ -function transformFrontendToApi(data: PricingData, itemTypeCode: 'PRODUCT' | 'MATERIAL' = 'MATERIAL'): Record { +function transformFrontendToApi(data: PricingData): Record { return { - item_type_code: itemTypeCode, + item_type_code: data.itemType, // 품목에서 가져온 실제 item_type 값 사용 item_id: parseInt(data.itemId), purchase_price: data.purchasePrice || null, processing_cost: data.processingCost || null, @@ -252,14 +253,14 @@ export async function getItemInfo(itemId: string): Promise { /** * 단가 등록 + * item_type_code는 data.itemType에서 자동으로 가져옴 */ export async function createPricing( - data: PricingData, - itemTypeCode: 'PRODUCT' | 'MATERIAL' = 'MATERIAL' + data: PricingData ): Promise<{ success: boolean; data?: PricingData; error?: string }> { try { const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data, itemTypeCode); + const apiData = transformFrontendToApi(data); console.log('[PricingActions] POST pricing request:', apiData); @@ -308,7 +309,7 @@ export async function updatePricing( const apiData = { ...transformFrontendToApi(data), change_reason: changeReason || null, - }; + } as Record; console.log('[PricingActions] PUT pricing request:', apiData); diff --git a/src/components/pricing/types.ts b/src/components/pricing/types.ts index ba8be511..c6cf81d4 100644 --- a/src/components/pricing/types.ts +++ b/src/components/pricing/types.ts @@ -111,7 +111,7 @@ export interface PricingListItem { itemId: string; itemCode: string; itemName: string; - itemType: string; + itemType: string; // FG, PT, SM, RM, CS 등 (API 등록 시 item_type_code로 사용) specification?: string; unit: string; purchasePrice?: number; @@ -122,7 +122,6 @@ export interface PricingListItem { status: PricingStatus | 'not_registered'; currentRevision: number; isFinal: boolean; - itemTypeCode?: 'PRODUCT' | 'MATERIAL'; // API 등록 시 필요 (PRODUCT 또는 MATERIAL) } // ===== 유틸리티 타입 =====