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)
}
// ===== 유틸리티 타입 =====