feat: [출하/배차/회계] 배차 다중행 + 어음 리팩토링 + 출고관리
- 배차차량관리 목업→API 연동, 배차정보 다중 행 - ShipmentManagement 출고관리 API 매핑 - BillManagement 리팩토링 (섹션 분리, hooks, constants) - 상품권 actions/types 확장 - 출하관리 캘린더 기본 뷰 week-time
This commit is contained in:
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
// HTML 설정
|
||||
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const resourceType = req.resourceType();
|
||||
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
|
||||
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
|
||||
req.abort();
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
|
||||
await page.setContent(fullHtml, {
|
||||
waitUntil: 'networkidle0',
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
@@ -51,12 +51,27 @@ import {
|
||||
getBankAccountOptions,
|
||||
getFinancialInstitutions,
|
||||
batchSaveTransactions,
|
||||
exportBankTransactionsExcel,
|
||||
type BankTransactionSummaryData,
|
||||
} from './actions';
|
||||
import { TransactionFormModal } from './TransactionFormModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
|
||||
{ header: '거래일시', key: 'transactionDate', width: 12 },
|
||||
{ header: '구분', key: 'type', width: 8,
|
||||
transform: (v) => v === 'deposit' ? '입금' : '출금' },
|
||||
{ header: '은행명', key: 'bankName', width: 12 },
|
||||
{ header: '계좌명', key: 'accountName', width: 15 },
|
||||
{ header: '적요/내용', key: 'note', width: 20 },
|
||||
{ header: '입금', key: 'depositAmount', width: 14 },
|
||||
{ header: '출금', key: 'withdrawalAmount', width: 14 },
|
||||
{ header: '잔액', key: 'balance', width: 14 },
|
||||
{ header: '취급점', key: 'branch', width: 12 },
|
||||
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||
const tableColumns = [
|
||||
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
|
||||
}
|
||||
}, [localChanges, loadData]);
|
||||
|
||||
// 엑셀 다운로드
|
||||
// 엑셀 다운로드 (프론트 xlsx 생성)
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportBankTransactionsExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.downloadUrl, '_blank');
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: BankTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getBankTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel({
|
||||
data: allData as (BankTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: '계좌입출금내역',
|
||||
sheetName: '입출금내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
|
||||
@@ -1,99 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import { apiDataToFormData, transformFormDataToApi } from './types';
|
||||
import type { BillApiData } from './types';
|
||||
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
|
||||
import { useBillForm } from './hooks/useBillForm';
|
||||
import { useBillConditions } from './hooks/useBillConditions';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
getBillStatusOptions,
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
BasicInfoSection,
|
||||
ElectronicBillSection,
|
||||
ExchangeBillSection,
|
||||
DiscountInfoSection,
|
||||
EndorsementSection,
|
||||
CollectionSection,
|
||||
HistorySection,
|
||||
RenewalSection,
|
||||
RecourseSection,
|
||||
BuybackSection,
|
||||
DishonoredSection,
|
||||
} from './sections';
|
||||
import { useDetailData } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// ===== 거래처 타입 =====
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
// 거래처 목록
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
// V8 폼 훅
|
||||
const {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
} = useBillForm();
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 조건부 표시 플래그
|
||||
const conditions = useBillConditions(formData);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
// 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
@@ -104,41 +69,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
// API 데이터 로딩 (BillApiData 그대로)
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
(id: string | number) => getBillRaw(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: billData,
|
||||
data: billApiData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
} = useDetailData<BillApiData>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
// API 데이터 → V8 폼 데이터로 변환
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
if (billApiData) {
|
||||
setFormDataFull(apiDataToFormData(billApiData));
|
||||
}
|
||||
}, [billData]);
|
||||
}, [billApiData, setFormDataFull]);
|
||||
|
||||
// ===== 로드 에러 처리 =====
|
||||
// 로드 에러
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
@@ -146,43 +100,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
const vendorId = conditions.isReceived ? formData.vendor : formData.payee;
|
||||
if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' };
|
||||
if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
}, [formData, conditions.isReceived, conditions.isBill]);
|
||||
|
||||
// ===== 제출 상태 =====
|
||||
// 제출
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
@@ -192,28 +124,26 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const billData: Partial<BillRecord> = {
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || '';
|
||||
const apiPayload = transformFormDataToApi(formData, vendorName);
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createBill(billData);
|
||||
const result = await createBillRaw(apiPayload);
|
||||
if (result.success) {
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return await updateBill(String(billId), billData);
|
||||
const result = await updateBillRaw(String(billId), apiPayload);
|
||||
return result;
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, clients, isNewMode, billId, validateForm, router]);
|
||||
}, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]);
|
||||
|
||||
// ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -223,284 +153,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [billId]);
|
||||
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 1. 기본 정보 */}
|
||||
<BasicInfoSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
clients={clients}
|
||||
conditions={conditions}
|
||||
onInstrumentTypeChange={handleInstrumentTypeChange}
|
||||
onDirectionChange={handleDirectionChange}
|
||||
/>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 2. 전자어음 정보 */}
|
||||
{conditions.showElectronic && (
|
||||
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 3. 환어음 정보 */}
|
||||
{conditions.showExchangeBill && (
|
||||
<ExchangeBillSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 4. 할인 정보 */}
|
||||
{conditions.showDiscount && (
|
||||
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 5. 배서양도 정보 */}
|
||||
{conditions.showEndorsement && (
|
||||
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 6. 추심 정보 */}
|
||||
{conditions.showCollection && (
|
||||
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 7. 이력 관리 (받을어음만) */}
|
||||
{conditions.isReceived && (
|
||||
<HistorySection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
isElectronic={conditions.isElectronic}
|
||||
maxSplitCount={conditions.maxSplitCount}
|
||||
onAddInstallment={addInstallment}
|
||||
onRemoveInstallment={removeInstallment}
|
||||
onUpdateInstallment={updateInstallment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 8. 개서 정보 */}
|
||||
{conditions.showRenewal && (
|
||||
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 9. 소구 정보 */}
|
||||
{conditions.showRecourse && (
|
||||
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 10. 환매 정보 */}
|
||||
{conditions.showBuyback && (
|
||||
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 11. 부도 정보 */}
|
||||
{conditions.showDishonored && (
|
||||
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// 템플릿 설정
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
title: isViewMode ? '어음/수표 상세' : '어음/수표',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - tableHeaderActions: 거래처, 구분, 상태 필터
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { useDateRange } from '@/hooks';
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -148,6 +146,16 @@ export function BillManagementClient({
|
||||
}
|
||||
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
|
||||
|
||||
// ===== 필터 변경 시 자동 재조회 =====
|
||||
const isInitialMount = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
loadData(1);
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
@@ -348,32 +356,8 @@ export function BillManagementClient({
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendorFilter',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'billType',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendorFilter: vendorFilter,
|
||||
billType: billTypeFilter,
|
||||
status: statusFilter,
|
||||
},
|
||||
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
|
||||
filterConfig: [],
|
||||
filterTitle: '어음 필터',
|
||||
|
||||
// 날짜 선택기
|
||||
@@ -392,44 +376,12 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
|
||||
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
|
||||
// 데스크톱: 모두 표시
|
||||
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-3" style={{ display: 'flex' }}>
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer text-sm whitespace-nowrap">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer text-sm whitespace-nowrap">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
@@ -448,7 +400,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
|
||||
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
@@ -461,7 +413,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="보관중" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -19,7 +19,8 @@ interface BillSummaryApiData {
|
||||
// ===== 어음 목록 조회 =====
|
||||
export async function getBills(params: {
|
||||
search?: string; billType?: string; status?: string; clientId?: string;
|
||||
isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string;
|
||||
isElectronic?: boolean; instrumentType?: string; medium?: string;
|
||||
issueStartDate?: string; issueEndDate?: string;
|
||||
maturityStartDate?: string; maturityEndDate?: string;
|
||||
sortBy?: string; sortDir?: string; perPage?: number; page?: number;
|
||||
}) {
|
||||
@@ -30,6 +31,8 @@ export async function getBills(params: {
|
||||
status: params.status && params.status !== 'all' ? params.status : undefined,
|
||||
client_id: params.clientId,
|
||||
is_electronic: params.isElectronic,
|
||||
instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined,
|
||||
medium: params.medium && params.medium !== 'all' ? params.medium : undefined,
|
||||
issue_start_date: params.issueStartDate,
|
||||
issue_end_date: params.issueEndDate,
|
||||
maturity_start_date: params.maturityStartDate,
|
||||
@@ -124,6 +127,34 @@ export async function getBillSummary(params: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 상세 조회 (BillApiData 그대로 반환) =====
|
||||
export async function getBillRaw(id: string): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
errorMessage: '어음 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 등록 (raw payload) =====
|
||||
export async function createBillRaw(data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bills'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '어음 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 수정 (raw payload) =====
|
||||
export async function updateBillRaw(id: string, data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
errorMessage: '어음 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
|
||||
return executeServerAction({
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
|
||||
* (차수 관리 테이블 등 특수 기능 유지)
|
||||
*/
|
||||
export const billConfig: DetailConfig = {
|
||||
title: '어음 상세',
|
||||
description: '어음 및 수취어음 상세 현황을 관리합니다',
|
||||
title: '어음/수표 상세',
|
||||
description: '어음/수표 상세 현황을 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/accounting/bills',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
@@ -25,8 +25,8 @@ export const billConfig: DetailConfig = {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
title: '어음/수표 삭제',
|
||||
description: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
178
src/components/accounting/BillManagement/constants.ts
Normal file
178
src/components/accounting/BillManagement/constants.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// ===== 증권종류 =====
|
||||
export const INSTRUMENT_TYPE_OPTIONS = [
|
||||
{ value: 'promissory', label: '약속어음' },
|
||||
{ value: 'exchange', label: '환어음' },
|
||||
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
|
||||
{ value: 'currentCheck', label: '당좌수표' },
|
||||
] as const;
|
||||
|
||||
// ===== 거래방향 =====
|
||||
export const DIRECTION_OPTIONS = [
|
||||
{ value: 'received', label: '수취 (받을어음)' },
|
||||
{ value: 'issued', label: '발행 (지급어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 전자/지류 =====
|
||||
export const MEDIUM_OPTIONS = [
|
||||
{ value: 'electronic', label: '전자' },
|
||||
{ value: 'paper', label: '지류 (종이)' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서 여부 =====
|
||||
export const ENDORSEMENT_OPTIONS = [
|
||||
{ value: 'endorsable', label: '배서 가능' },
|
||||
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 어음구분 =====
|
||||
export const BILL_CATEGORY_OPTIONS = [
|
||||
{ value: 'commercial', label: '상업어음 (매출채권)' },
|
||||
{ value: 'other', label: '기타어음 (대여금/미수금)' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을어음 - 결제상태 (어음용) =====
|
||||
export const RECEIVED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'discounted', label: '할인' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'recourse', label: '소구 (배서어음 상환)' },
|
||||
{ value: 'buyback', label: '환매 (할인어음 부도)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을수표 - 결제상태 (수표용) =====
|
||||
export const RECEIVED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'deposited', label: '추심입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료 (제시입금)' },
|
||||
{ value: 'recourse', label: '소구 (수표법 제39조)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급어음 - 지급상태 =====
|
||||
export const ISSUED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityPayment', label: '만기결제' },
|
||||
{ value: 'paid', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급수표 - 지급상태 =====
|
||||
export const ISSUED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '미결제' },
|
||||
{ value: 'paid', label: '결제완료 (제시출금)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 결제방법 =====
|
||||
export const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'autoTransfer', label: '만기자동이체' },
|
||||
{ value: 'currentAccount', label: '당좌결제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 부도사유 =====
|
||||
export const DISHONOR_REASON_OPTIONS = [
|
||||
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
|
||||
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
|
||||
{ value: 'formal_defect', label: '형식불비' },
|
||||
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
|
||||
{ value: 'expired', label: '제시기간 경과' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 이력 처리구분 =====
|
||||
export const HISTORY_TYPE_OPTIONS = [
|
||||
{ value: 'received', label: '수취' },
|
||||
{ value: 'endorsement', label: '배서양도' },
|
||||
{ value: 'splitEndorsement', label: '분할배서' },
|
||||
{ value: 'collection', label: '추심의뢰' },
|
||||
{ value: 'collectionDeposit', label: '추심입금' },
|
||||
{ value: 'discount', label: '할인' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
{ value: 'recourse', label: '소구' },
|
||||
{ value: 'buyback', label: '환매' },
|
||||
{ value: 'renewal', label: '개서' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서차수 (지류: 4차, 전자: 20차) =====
|
||||
export const ENDORSEMENT_ORDER_PAPER = [
|
||||
{ value: '1', label: '1차 (발행인 직접수취)' },
|
||||
{ value: '2', label: '2차 (1개 업체 경유)' },
|
||||
{ value: '3', label: '3차 (2개 업체 경유)' },
|
||||
{ value: '4', label: '4차 (3개 업체 경유)' },
|
||||
] as const;
|
||||
|
||||
export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`,
|
||||
}));
|
||||
|
||||
// ===== 보관장소 =====
|
||||
export const STORAGE_OPTIONS = [
|
||||
{ value: 'safe', label: '금고' },
|
||||
{ value: 'bank', label: '은행 보관' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급장소 (어음법 제75조) =====
|
||||
export const PAYMENT_PLACE_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'payerAddress', label: '지급인 주소지' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 지급장소 (수표법 제3조: 은행만) =====
|
||||
export const PAYMENT_PLACE_CHECK_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
] as const;
|
||||
|
||||
// ===== 추심결과 =====
|
||||
export const COLLECTION_RESULT_OPTIONS = [
|
||||
{ value: 'success', label: '추심 성공 (입금완료)' },
|
||||
{ value: 'partial', label: '일부 입금' },
|
||||
{ value: 'failed', label: '추심 실패 (부도)' },
|
||||
{ value: 'pending', label: '추심 진행중' },
|
||||
] as const;
|
||||
|
||||
// ===== 소구사유 =====
|
||||
export const RECOURSE_REASON_OPTIONS = [
|
||||
{ value: 'endorsedDishonor', label: '배서양도 어음 부도' },
|
||||
{ value: 'discountDishonor', label: '할인 어음 부도 (환매)' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 인수거절 사유 =====
|
||||
export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [
|
||||
{ value: 'financialDifficulty', label: '자금 사정 곤란' },
|
||||
{ value: 'disputeOfClaim', label: '채권 분쟁' },
|
||||
{ value: 'amountDispute', label: '금액 이의' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 개서 사유 =====
|
||||
export const RENEWAL_REASON_OPTIONS = [
|
||||
{ value: 'maturityExtension', label: '만기일 연장' },
|
||||
{ value: 'amountChange', label: '금액 변경' },
|
||||
{ value: 'conditionChange', label: '조건 변경' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) =====
|
||||
export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored'];
|
||||
export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored'];
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import {
|
||||
RECEIVED_STATUS_OPTIONS,
|
||||
RECEIVED_CHECK_STATUS_OPTIONS,
|
||||
ISSUED_STATUS_OPTIONS,
|
||||
ISSUED_CHECK_STATUS_OPTIONS,
|
||||
PAYMENT_PLACE_OPTIONS,
|
||||
PAYMENT_PLACE_CHECK_OPTIONS,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillConditions(formData: BillFormData) {
|
||||
return useMemo(() => {
|
||||
const isReceived = formData.direction === 'received';
|
||||
const isIssued = formData.direction === 'issued';
|
||||
const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck';
|
||||
const isBill = !isCheck;
|
||||
const canBeElectronic = formData.instrumentType === 'promissory';
|
||||
const isElectronic = formData.medium === 'electronic';
|
||||
|
||||
const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus;
|
||||
|
||||
// 조건부 섹션 표시 플래그
|
||||
const showElectronic = isElectronic;
|
||||
const showExchangeBill = formData.instrumentType === 'exchange';
|
||||
const showDiscount = isReceived && formData.isDiscounted && isBill;
|
||||
const showEndorsement = isReceived && formData.receivedStatus === 'endorsed';
|
||||
const showCollection = isReceived && formData.receivedStatus === 'collected';
|
||||
const showDishonored = currentStatus === 'dishonored';
|
||||
const showRenewal = currentStatus === 'renewed' && isBill;
|
||||
const showRecourse = isReceived && formData.receivedStatus === 'recourse';
|
||||
const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill;
|
||||
const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused';
|
||||
|
||||
// 현재 증권종류에 맞는 옵션 목록
|
||||
const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS;
|
||||
const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS;
|
||||
const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS;
|
||||
|
||||
// 분할배서 최대 횟수
|
||||
const maxSplitCount = isElectronic ? 4 : 10;
|
||||
|
||||
return {
|
||||
isReceived,
|
||||
isIssued,
|
||||
isCheck,
|
||||
isBill,
|
||||
canBeElectronic,
|
||||
isElectronic,
|
||||
currentStatus,
|
||||
showElectronic,
|
||||
showExchangeBill,
|
||||
showDiscount,
|
||||
showEndorsement,
|
||||
showCollection,
|
||||
showDishonored,
|
||||
showRenewal,
|
||||
showRecourse,
|
||||
showBuyback,
|
||||
showAcceptanceRefusal,
|
||||
receivedStatusOptions,
|
||||
issuedStatusOptions,
|
||||
paymentPlaceOptions,
|
||||
maxSplitCount,
|
||||
};
|
||||
}, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]);
|
||||
}
|
||||
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import { INITIAL_BILL_FORM_DATA } from '../types';
|
||||
import {
|
||||
VALID_CHECK_RECEIVED_STATUSES,
|
||||
VALID_CHECK_ISSUED_STATUSES,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillForm(initialData?: Partial<BillFormData>) {
|
||||
const [formData, setFormData] = useState<BillFormData>({
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
...initialData,
|
||||
});
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(field: K, value: BillFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 증권종류 변경 시 연관 필드 초기화
|
||||
const handleInstrumentTypeChange = useCallback((newType: string) => {
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] };
|
||||
const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck';
|
||||
|
||||
// 약속어음 외에는 전자 불가 → 지류로 리셋
|
||||
if (newType !== 'promissory' && prev.medium === 'electronic') {
|
||||
next.medium = 'paper';
|
||||
}
|
||||
|
||||
// 수표 전환 시: 만기일, 할인, 관련 필드 리셋
|
||||
if (isCheckType) {
|
||||
next.maturityDate = '';
|
||||
next.isDiscounted = false;
|
||||
if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) {
|
||||
next.receivedStatus = 'stored';
|
||||
}
|
||||
if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) {
|
||||
next.issuedStatus = 'stored';
|
||||
}
|
||||
if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') {
|
||||
next.paymentPlace = '';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 거래방향 변경 시 상태 초기화
|
||||
const handleDirectionChange = useCallback((newDirection: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
direction: newDirection as BillFormData['direction'],
|
||||
receivedStatus: 'stored',
|
||||
issuedStatus: 'stored',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 이력 관리
|
||||
const addInstallment = useCallback(() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [
|
||||
...prev.installments,
|
||||
{ id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' },
|
||||
],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateInstallment = useCallback((id: string, field: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 폼 전체 덮어쓰기 (API 데이터 로드 시)
|
||||
const setFormDataFull = useCallback((data: BillFormData) => {
|
||||
setFormData(data);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import {
|
||||
INSTRUMENT_TYPE_OPTIONS,
|
||||
DIRECTION_OPTIONS,
|
||||
MEDIUM_OPTIONS,
|
||||
ENDORSEMENT_OPTIONS,
|
||||
BILL_CATEGORY_OPTIONS,
|
||||
STORAGE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
ENDORSEMENT_ORDER_PAPER,
|
||||
ENDORSEMENT_ORDER_ELECTRONIC,
|
||||
} from '../constants';
|
||||
|
||||
interface BasicInfoSectionProps extends SectionProps {
|
||||
clients: { id: string; name: string }[];
|
||||
conditions: {
|
||||
isReceived: boolean;
|
||||
isIssued: boolean;
|
||||
isCheck: boolean;
|
||||
isBill: boolean;
|
||||
canBeElectronic: boolean;
|
||||
isElectronic: boolean;
|
||||
receivedStatusOptions: readonly { value: string; label: string }[];
|
||||
issuedStatusOptions: readonly { value: string; label: string }[];
|
||||
paymentPlaceOptions: readonly { value: string; label: string }[];
|
||||
};
|
||||
onInstrumentTypeChange: (v: string) => void;
|
||||
onDirectionChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export function BasicInfoSection({
|
||||
formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange,
|
||||
}: BasicInfoSectionProps) {
|
||||
const {
|
||||
isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic,
|
||||
receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions,
|
||||
} = conditions;
|
||||
|
||||
const endorsementOrderOptions = useMemo(
|
||||
() => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER],
|
||||
[isElectronic]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>어음번호 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.billNumber} onChange={(e) => updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span>
|
||||
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 전자 가능)</span>}
|
||||
</Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={isReceived ? formData.vendor : formData.payee}
|
||||
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 (수표는 일람출급이므로 없음) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
|
||||
<Input
|
||||
value={isReceived ? formData.issuerBank : formData.settlementBank}
|
||||
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
|
||||
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 <span className="text-red-500">*</span>
|
||||
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
|
||||
</Label>
|
||||
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 상세 */}
|
||||
{formData.paymentPlace === 'other' && (
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 상세</Label>
|
||||
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 어음구분 (어음만) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>어음구분</Label>
|
||||
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 받을어음 전용 필드 ===== */}
|
||||
{isReceived && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부</Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>배서차수</Label>
|
||||
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>보관장소</Label>
|
||||
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>결제상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 할인여부 (수표 제외) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>할인여부</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
|
||||
updateField('isDiscounted', c);
|
||||
if (c) updateField('receivedStatus', 'discounted');
|
||||
}} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 지급어음 전용 필드 ===== */}
|
||||
{isIssued && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>결제방법</Label>
|
||||
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>지급상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>실제결제일</Label>
|
||||
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 입출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌</Label>
|
||||
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">환매 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>환매일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매요청 은행</Label>
|
||||
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { COLLECTION_RESULT_OPTIONS } from '../constants';
|
||||
|
||||
export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">추심 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 추심 의뢰 */}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">추심 의뢰</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료</Label>
|
||||
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 추심 결과 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">추심 결과</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심결과</Label>
|
||||
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심완료일</Label>
|
||||
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금일</Label>
|
||||
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금액 (수수료 차감후)</Label>
|
||||
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
const calcNetReceived = useMemo(() => {
|
||||
if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount;
|
||||
return 0;
|
||||
}, [formData.amount, formData.discountAmount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">할인 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>할인일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)</Label>
|
||||
<Input type="number" step="0.01" min={0} max={100} value={formData.discountRate || ''} onChange={(e) => {
|
||||
const rate = parseFloat(e.target.value) || 0;
|
||||
updateField('discountRate', rate);
|
||||
if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100));
|
||||
}} placeholder="예: 3.5" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액</Label>
|
||||
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>실수령액 (자동계산)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
|
||||
{calcNetReceived > 0
|
||||
? <span className="text-green-700">₩ {calcNetReceived.toLocaleString()}</span>
|
||||
: <span className="text-gray-400">어음금액 - 할인금액</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { DISHONOR_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
|
||||
updateField('dishonoredDate', d);
|
||||
if (d) {
|
||||
const dt = new Date(d);
|
||||
dt.setDate(dt.getDate() + 6);
|
||||
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
|
||||
}
|
||||
}} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 법적 프로세스 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">법적 프로세스 (어음법 제44조·제45조)</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{formData.hasProtest && (
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성일</Label>
|
||||
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>소구 통지일</Label>
|
||||
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>통지 기한 (자동: 부도일+4영업일)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
|
||||
{formData.recourseNoticeDeadline ? (
|
||||
<span className={
|
||||
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
|
||||
? 'text-green-700' : 'text-red-600 font-medium'
|
||||
}>
|
||||
{formData.recourseNoticeDeadline}
|
||||
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
|
||||
</span>
|
||||
) : <span className="text-gray-400">부도일자 입력 시 자동계산</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">전자어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호</Label>
|
||||
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관</Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">배서양도 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>배서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유</Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment">대금결제</SelectItem>
|
||||
<SelectItem value="guarantee">담보제공</SelectItem>
|
||||
<SelectItem value="collection">추심위임</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
interface ExchangeBillSectionProps extends SectionProps {
|
||||
showAcceptanceRefusal: boolean;
|
||||
}
|
||||
|
||||
export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">환어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>지급인 (Drawee) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부</Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accepted">인수 완료</SelectItem>
|
||||
<SelectItem value="pending">인수 대기</SelectItem>
|
||||
<SelectItem value="refused">인수 거절</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
|
||||
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showAcceptanceRefusal && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>인수거절 사유</Label>
|
||||
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { BillFormData } from '../types';
|
||||
import { HISTORY_TYPE_OPTIONS } from '../constants';
|
||||
|
||||
interface HistorySectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
isElectronic: boolean;
|
||||
maxSplitCount: number;
|
||||
onAddInstallment: () => void;
|
||||
onRemoveInstallment: (id: string) => void;
|
||||
onUpdateInstallment: (id: string, field: string, value: string | number) => void;
|
||||
}
|
||||
|
||||
export function HistorySection({
|
||||
formData, updateField, isViewMode, isElectronic, maxSplitCount,
|
||||
onAddInstallment, onRemoveInstallment, onUpdateInstallment,
|
||||
}: HistorySectionProps) {
|
||||
const splitEndorsementStats = useMemo(() => {
|
||||
const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement');
|
||||
const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0);
|
||||
return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount };
|
||||
}, [formData.installments, formData.amount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">이력 관리</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 분할배서 토글 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
|
||||
<Label>분할배서 허용</Label>
|
||||
{formData.isSplit && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
최대 {maxSplitCount}회
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{formData.isSplit && isElectronic && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조)</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.isSplit && splitEndorsementStats.count > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
|
||||
<span className="text-muted-foreground">원금액:</span>
|
||||
<span className="font-semibold">₩ {formData.amount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 분할배서 합계:</span>
|
||||
<span className="font-semibold text-blue-600">₩ {splitEndorsementStats.totalAmount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 잔액:</span>
|
||||
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
₩ {splitEndorsementStats.remaining.toLocaleString()}
|
||||
</span>
|
||||
{splitEndorsementStats.remaining < 0 && (
|
||||
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" />금액 초과</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[130px]">처리구분</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8">등록된 이력이 없습니다</TableCell>
|
||||
</TableRow>
|
||||
) : formData.installments.map((inst, idx) => (
|
||||
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HISTORY_TYPE_OPTIONS
|
||||
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
|
||||
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RECOURSE_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">소구 (상환) 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>소구일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구대상 (청구인)</Label>
|
||||
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구사유</Label>
|
||||
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RENEWAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
|
||||
개서 정보
|
||||
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50">만기연장</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>개서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>신어음번호</Label>
|
||||
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>개서 사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { BasicInfoSection } from './BasicInfoSection';
|
||||
export { ElectronicBillSection } from './ElectronicBillSection';
|
||||
export { ExchangeBillSection } from './ExchangeBillSection';
|
||||
export { DiscountInfoSection } from './DiscountInfoSection';
|
||||
export { EndorsementSection } from './EndorsementSection';
|
||||
export { CollectionSection } from './CollectionSection';
|
||||
export { HistorySection } from './HistorySection';
|
||||
export { RenewalSection } from './RenewalSection';
|
||||
export { RecourseSection } from './RecourseSection';
|
||||
export { BuybackSection } from './BuybackSection';
|
||||
export { DishonoredSection } from './DishonoredSection';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BillFormData } from '../types';
|
||||
|
||||
export interface SectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
}
|
||||
@@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) {
|
||||
export interface BillApiInstallment {
|
||||
id: number;
|
||||
bill_id: number;
|
||||
type?: string;
|
||||
installment_date: string;
|
||||
amount: string;
|
||||
counterparty?: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -190,7 +192,7 @@ export interface BillApiData {
|
||||
client_name: string | null;
|
||||
amount: string;
|
||||
issue_date: string;
|
||||
maturity_date: string;
|
||||
maturity_date: string | null;
|
||||
status: BillStatus;
|
||||
reason: string | null;
|
||||
installment_count: number;
|
||||
@@ -211,6 +213,58 @@ export interface BillApiData {
|
||||
account_name: string;
|
||||
} | null;
|
||||
installments?: BillApiInstallment[];
|
||||
// V8 확장 필드
|
||||
instrument_type?: string;
|
||||
medium?: string;
|
||||
bill_category?: string;
|
||||
electronic_bill_no?: string | null;
|
||||
registration_org?: string | null;
|
||||
drawee?: string | null;
|
||||
acceptance_status?: string | null;
|
||||
acceptance_date?: string | null;
|
||||
acceptance_refusal_date?: string | null;
|
||||
acceptance_refusal_reason?: string | null;
|
||||
endorsement?: string | null;
|
||||
endorsement_order?: string | null;
|
||||
storage_place?: string | null;
|
||||
issuer_bank?: string | null;
|
||||
is_discounted?: boolean;
|
||||
discount_date?: string | null;
|
||||
discount_bank?: string | null;
|
||||
discount_rate?: string | null;
|
||||
discount_amount?: string | null;
|
||||
endorsement_date?: string | null;
|
||||
endorsee?: string | null;
|
||||
endorsement_reason?: string | null;
|
||||
collection_bank?: string | null;
|
||||
collection_request_date?: string | null;
|
||||
collection_fee?: string | null;
|
||||
collection_complete_date?: string | null;
|
||||
collection_result?: string | null;
|
||||
collection_deposit_date?: string | null;
|
||||
collection_deposit_amount?: string | null;
|
||||
settlement_bank?: string | null;
|
||||
payment_method?: string | null;
|
||||
actual_payment_date?: string | null;
|
||||
payment_place?: string | null;
|
||||
payment_place_detail?: string | null;
|
||||
renewal_date?: string | null;
|
||||
renewal_new_bill_no?: string | null;
|
||||
renewal_reason?: string | null;
|
||||
recourse_date?: string | null;
|
||||
recourse_amount?: string | null;
|
||||
recourse_target?: string | null;
|
||||
recourse_reason?: string | null;
|
||||
buyback_date?: string | null;
|
||||
buyback_amount?: string | null;
|
||||
buyback_bank?: string | null;
|
||||
dishonored_date?: string | null;
|
||||
dishonored_reason?: string | null;
|
||||
has_protest?: boolean;
|
||||
protest_date?: string | null;
|
||||
recourse_notice_date?: string | null;
|
||||
recourse_notice_deadline?: string | null;
|
||||
is_split?: boolean;
|
||||
}
|
||||
|
||||
export interface BillApiResponse {
|
||||
@@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
vendorName: apiData.client?.name || apiData.client_name || '',
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
status: apiData.status,
|
||||
reason: apiData.reason || '',
|
||||
installmentCount: apiData.installment_count,
|
||||
@@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 함수 =====
|
||||
// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) =====
|
||||
export function transformFrontendToApi(data: Partial<BillRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
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.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
|
||||
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;
|
||||
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
|
||||
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
|
||||
const isReceived = data.direction === 'received';
|
||||
const orNull = (v: string) => v || null;
|
||||
const orNullNum = (v: number) => v || null;
|
||||
const orNullDate = (v: string) => v || null;
|
||||
|
||||
return {
|
||||
// 기존 12개 필드
|
||||
bill_number: data.billNumber,
|
||||
bill_type: data.direction,
|
||||
client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null),
|
||||
client_name: vendorName || null,
|
||||
amount: data.amount,
|
||||
issue_date: data.issueDate,
|
||||
maturity_date: orNullDate(data.maturityDate),
|
||||
status: isReceived ? data.receivedStatus : data.issuedStatus,
|
||||
note: orNull(data.note),
|
||||
is_electronic: data.medium === 'electronic',
|
||||
// V8 확장 필드
|
||||
instrument_type: data.instrumentType,
|
||||
medium: data.medium,
|
||||
bill_category: orNull(data.billCategory),
|
||||
electronic_bill_no: orNull(data.electronicBillNo),
|
||||
registration_org: orNull(data.registrationOrg),
|
||||
drawee: orNull(data.drawee),
|
||||
acceptance_status: orNull(data.acceptanceStatus),
|
||||
acceptance_date: orNullDate(data.acceptanceDate),
|
||||
acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate),
|
||||
acceptance_refusal_reason: orNull(data.acceptanceRefusalReason),
|
||||
endorsement: orNull(data.endorsement),
|
||||
endorsement_order: orNull(data.endorsementOrder),
|
||||
storage_place: orNull(data.storagePlace),
|
||||
issuer_bank: orNull(data.issuerBank),
|
||||
is_discounted: data.isDiscounted,
|
||||
discount_date: orNullDate(data.discountDate),
|
||||
discount_bank: orNull(data.discountBank),
|
||||
discount_rate: orNullNum(data.discountRate),
|
||||
discount_amount: orNullNum(data.discountAmount),
|
||||
endorsement_date: orNullDate(data.endorsementDate),
|
||||
endorsee: orNull(data.endorsee),
|
||||
endorsement_reason: orNull(data.endorsementReason),
|
||||
collection_bank: orNull(data.collectionBank),
|
||||
collection_request_date: orNullDate(data.collectionRequestDate),
|
||||
collection_fee: orNullNum(data.collectionFee),
|
||||
collection_complete_date: orNullDate(data.collectionCompleteDate),
|
||||
collection_result: orNull(data.collectionResult),
|
||||
collection_deposit_date: orNullDate(data.collectionDepositDate),
|
||||
collection_deposit_amount: orNullNum(data.collectionDepositAmount),
|
||||
settlement_bank: orNull(data.settlementBank),
|
||||
payment_method: orNull(data.paymentMethod),
|
||||
actual_payment_date: orNullDate(data.actualPaymentDate),
|
||||
payment_place: orNull(data.paymentPlace),
|
||||
payment_place_detail: orNull(data.paymentPlaceDetail),
|
||||
renewal_date: orNullDate(data.renewalDate),
|
||||
renewal_new_bill_no: orNull(data.renewalNewBillNo),
|
||||
renewal_reason: orNull(data.renewalReason),
|
||||
recourse_date: orNullDate(data.recourseDate),
|
||||
recourse_amount: orNullNum(data.recourseAmount),
|
||||
recourse_target: orNull(data.recourseTarget),
|
||||
recourse_reason: orNull(data.recourseReason),
|
||||
buyback_date: orNullDate(data.buybackDate),
|
||||
buyback_amount: orNullNum(data.buybackAmount),
|
||||
buyback_bank: orNull(data.buybackBank),
|
||||
dishonored_date: orNullDate(data.dishonoredDate),
|
||||
dishonored_reason: orNull(data.dishonoredReason),
|
||||
has_protest: data.hasProtest,
|
||||
protest_date: orNullDate(data.protestDate),
|
||||
recourse_notice_date: orNullDate(data.recourseNoticeDate),
|
||||
recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline),
|
||||
is_split: data.isSplit,
|
||||
// 이력(차수)
|
||||
installments: data.installments.map(inst => ({
|
||||
date: inst.date,
|
||||
type: inst.type || 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: orNull(inst.counterparty),
|
||||
note: orNull(inst.note),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션)
|
||||
// =============================================
|
||||
|
||||
// ===== 증권종류 =====
|
||||
export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck';
|
||||
|
||||
// ===== 거래방향 (Direction = BillType alias) =====
|
||||
export type Direction = 'received' | 'issued';
|
||||
|
||||
// ===== 매체 =====
|
||||
export type Medium = 'electronic' | 'paper';
|
||||
|
||||
// ===== 이력 레코드 (V8: 처리구분/상대처 추가) =====
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
type: string; // 처리구분 (HISTORY_TYPE_OPTIONS)
|
||||
amount: number;
|
||||
counterparty: string; // 상대처
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ===== V8 폼 데이터 (전체 ~45개 필드) =====
|
||||
export interface BillFormData {
|
||||
// === 공통 ===
|
||||
billNumber: string;
|
||||
instrumentType: InstrumentType;
|
||||
direction: Direction;
|
||||
medium: Medium;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
note: string;
|
||||
// === 전자어음 (조건: medium=electronic) ===
|
||||
electronicBillNo: string;
|
||||
registrationOrg: string;
|
||||
// === 환어음 (조건: instrumentType=exchange) ===
|
||||
drawee: string;
|
||||
acceptanceStatus: string;
|
||||
acceptanceDate: string;
|
||||
// === 받을어음 전용 ===
|
||||
vendor: string;
|
||||
billCategory: string;
|
||||
issuerBank: string;
|
||||
endorsement: string;
|
||||
endorsementOrder: string;
|
||||
storagePlace: string;
|
||||
receivedStatus: string;
|
||||
isDiscounted: boolean;
|
||||
discountDate: string;
|
||||
discountBank: string;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
// 배서양도
|
||||
endorsementDate: string;
|
||||
endorsee: string;
|
||||
endorsementReason: string;
|
||||
// 추심
|
||||
collectionBank: string;
|
||||
collectionRequestDate: string;
|
||||
collectionFee: number;
|
||||
collectionCompleteDate: string;
|
||||
collectionResult: string;
|
||||
collectionDepositDate: string;
|
||||
collectionDepositAmount: number;
|
||||
// === 지급어음 전용 ===
|
||||
payee: string;
|
||||
settlementBank: string;
|
||||
paymentMethod: string;
|
||||
issuedStatus: string;
|
||||
actualPaymentDate: string;
|
||||
// === 공통 ===
|
||||
paymentPlace: string;
|
||||
paymentPlaceDetail: string;
|
||||
// === 개서 ===
|
||||
renewalDate: string;
|
||||
renewalNewBillNo: string;
|
||||
renewalReason: string;
|
||||
// === 소구/환매 ===
|
||||
recourseDate: string;
|
||||
recourseAmount: number;
|
||||
recourseTarget: string;
|
||||
recourseReason: string;
|
||||
buybackDate: string;
|
||||
buybackAmount: number;
|
||||
buybackBank: string;
|
||||
// === 환어음 인수거절 ===
|
||||
acceptanceRefusalDate: string;
|
||||
acceptanceRefusalReason: string;
|
||||
// === 공통 조건부 ===
|
||||
isSplit: boolean;
|
||||
splitCount: number;
|
||||
splitAmount: number;
|
||||
dishonoredDate: string;
|
||||
dishonoredReason: string;
|
||||
// 부도 법적 프로세스
|
||||
hasProtest: boolean;
|
||||
protestDate: string;
|
||||
recourseNoticeDate: string;
|
||||
recourseNoticeDeadline: string;
|
||||
// === 이력 관리 ===
|
||||
installments: HistoryRecord[];
|
||||
// === 입출금 계좌 ===
|
||||
bankAccountInfo: string;
|
||||
}
|
||||
|
||||
// ===== 초기 폼 데이터 =====
|
||||
export const INITIAL_BILL_FORM_DATA: BillFormData = {
|
||||
billNumber: '', instrumentType: 'promissory', direction: 'received',
|
||||
medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '',
|
||||
electronicBillNo: '', registrationOrg: '',
|
||||
drawee: '', acceptanceStatus: '', acceptanceDate: '',
|
||||
vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1',
|
||||
storagePlace: '', receivedStatus: 'stored', isDiscounted: false,
|
||||
discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0,
|
||||
endorsementDate: '', endorsee: '', endorsementReason: '',
|
||||
collectionBank: '', collectionRequestDate: '', collectionFee: 0,
|
||||
collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0,
|
||||
payee: '', settlementBank: '', paymentMethod: 'autoTransfer',
|
||||
issuedStatus: 'stored', actualPaymentDate: '',
|
||||
paymentPlace: '', paymentPlaceDetail: '',
|
||||
renewalDate: '', renewalNewBillNo: '', renewalReason: '',
|
||||
recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '',
|
||||
buybackDate: '', buybackAmount: 0, buybackBank: '',
|
||||
acceptanceRefusalDate: '', acceptanceRefusalReason: '',
|
||||
isSplit: false, splitCount: 0, splitAmount: 0,
|
||||
dishonoredDate: '', dishonoredReason: '',
|
||||
hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '',
|
||||
installments: [], bankAccountInfo: '',
|
||||
};
|
||||
|
||||
// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) =====
|
||||
export function apiDataToFormData(apiData: BillApiData): BillFormData {
|
||||
const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0;
|
||||
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: apiData.bill_number,
|
||||
instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory',
|
||||
direction: apiData.bill_type as Direction,
|
||||
medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'),
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
note: apiData.note || '',
|
||||
// 전자어음
|
||||
electronicBillNo: apiData.electronic_bill_no || '',
|
||||
registrationOrg: apiData.registration_org || '',
|
||||
// 환어음
|
||||
drawee: apiData.drawee || '',
|
||||
acceptanceStatus: apiData.acceptance_status || '',
|
||||
acceptanceDate: apiData.acceptance_date || '',
|
||||
acceptanceRefusalDate: apiData.acceptance_refusal_date || '',
|
||||
acceptanceRefusalReason: apiData.acceptance_refusal_reason || '',
|
||||
// 거래처
|
||||
vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
// 받을어음 전용
|
||||
billCategory: apiData.bill_category || 'commercial',
|
||||
issuerBank: apiData.issuer_bank || '',
|
||||
endorsement: apiData.endorsement || 'endorsable',
|
||||
endorsementOrder: apiData.endorsement_order || '1',
|
||||
storagePlace: apiData.storage_place || '',
|
||||
receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored',
|
||||
isDiscounted: apiData.is_discounted ?? false,
|
||||
discountDate: apiData.discount_date || '',
|
||||
discountBank: apiData.discount_bank || '',
|
||||
discountRate: pf(apiData.discount_rate),
|
||||
discountAmount: pf(apiData.discount_amount),
|
||||
endorsementDate: apiData.endorsement_date || '',
|
||||
endorsee: apiData.endorsee || '',
|
||||
endorsementReason: apiData.endorsement_reason || '',
|
||||
collectionBank: apiData.collection_bank || '',
|
||||
collectionRequestDate: apiData.collection_request_date || '',
|
||||
collectionFee: pf(apiData.collection_fee),
|
||||
collectionCompleteDate: apiData.collection_complete_date || '',
|
||||
collectionResult: apiData.collection_result || '',
|
||||
collectionDepositDate: apiData.collection_deposit_date || '',
|
||||
collectionDepositAmount: pf(apiData.collection_deposit_amount),
|
||||
// 지급어음 전용
|
||||
settlementBank: apiData.settlement_bank || '',
|
||||
paymentMethod: apiData.payment_method || 'autoTransfer',
|
||||
issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored',
|
||||
actualPaymentDate: apiData.actual_payment_date || '',
|
||||
// 공통
|
||||
paymentPlace: apiData.payment_place || '',
|
||||
paymentPlaceDetail: apiData.payment_place_detail || '',
|
||||
// 개서
|
||||
renewalDate: apiData.renewal_date || '',
|
||||
renewalNewBillNo: apiData.renewal_new_bill_no || '',
|
||||
renewalReason: apiData.renewal_reason || '',
|
||||
// 소구/환매
|
||||
recourseDate: apiData.recourse_date || '',
|
||||
recourseAmount: pf(apiData.recourse_amount),
|
||||
recourseTarget: apiData.recourse_target || '',
|
||||
recourseReason: apiData.recourse_reason || '',
|
||||
buybackDate: apiData.buyback_date || '',
|
||||
buybackAmount: pf(apiData.buyback_amount),
|
||||
buybackBank: apiData.buyback_bank || '',
|
||||
// 부도
|
||||
isSplit: apiData.is_split ?? false,
|
||||
splitCount: 0,
|
||||
splitAmount: 0,
|
||||
dishonoredDate: apiData.dishonored_date || '',
|
||||
dishonoredReason: apiData.dishonored_reason || '',
|
||||
hasProtest: apiData.has_protest ?? false,
|
||||
protestDate: apiData.protest_date || '',
|
||||
recourseNoticeDate: apiData.recourse_notice_date || '',
|
||||
recourseNoticeDeadline: apiData.recourse_notice_deadline || '',
|
||||
// 이력
|
||||
installments: (apiData.installments || []).map(inst => ({
|
||||
id: String(inst.id),
|
||||
date: inst.installment_date,
|
||||
type: inst.type || 'other',
|
||||
amount: parseFloat(inst.amount),
|
||||
counterparty: inst.counterparty || '',
|
||||
note: inst.note || '',
|
||||
})),
|
||||
bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== BillRecord → BillFormData 변환 (하위호환 유지) =====
|
||||
export function billRecordToFormData(record: BillRecord): BillFormData {
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: record.billNumber,
|
||||
direction: record.billType as Direction,
|
||||
amount: record.amount,
|
||||
issueDate: record.issueDate,
|
||||
maturityDate: record.maturityDate,
|
||||
note: record.note,
|
||||
receivedStatus: record.billType === 'received' ? record.status : 'stored',
|
||||
issuedStatus: record.billType === 'issued' ? record.status : 'stored',
|
||||
vendor: record.billType === 'received' ? record.vendorId : '',
|
||||
payee: record.billType === 'issued' ? record.vendorId : '',
|
||||
installments: record.installments.map(inst => ({
|
||||
id: inst.id,
|
||||
date: inst.date,
|
||||
type: 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: '',
|
||||
note: inst.note,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { filterByEnum } from '@/lib/utils/search';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<CardTransaction>[] = [
|
||||
{ header: '사용일시', key: 'usedAt', width: 18 },
|
||||
{ header: '카드사', key: 'cardCompany', width: 10 },
|
||||
{ header: '카드번호', key: 'card', width: 12 },
|
||||
{ header: '카드명', key: 'cardName', width: 12 },
|
||||
{ header: '공제', key: 'deductionType', width: 10,
|
||||
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
|
||||
{ header: '사업자번호', key: 'businessNumber', width: 15 },
|
||||
{ header: '가맹점명', key: 'merchantName', width: 15 },
|
||||
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
|
||||
{ header: '내역', key: 'description', width: 15 },
|
||||
{ header: '합계금액', key: 'totalAmount', width: 12 },
|
||||
{ header: '공급가액', key: 'supplyAmount', width: 12 },
|
||||
{ header: '세액', key: 'taxAmount', width: 10 },
|
||||
{ header: '계정과목', key: 'accountSubject', width: 12,
|
||||
transform: (v) => {
|
||||
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
|
||||
return found?.label || String(v || '');
|
||||
}},
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
|
||||
const tableColumns = [
|
||||
@@ -269,9 +292,45 @@ export function CardTransactionInquiry() {
|
||||
setShowJournalEntry(true);
|
||||
}, []);
|
||||
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
|
||||
}, []);
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: CardTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getCardTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel<CardTransaction & Record<string, unknown>>({
|
||||
data: allData as (CardTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
|
||||
filename: '카드사용내역',
|
||||
sheetName: '카드사용내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, searchQuery]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<CardTransaction> = useMemo(
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { printElement } from '@/lib/print-utils';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
@@ -204,9 +205,22 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const printAreaRef = useRef<HTMLDivElement>(null);
|
||||
const handlePrint = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
if (printAreaRef.current) {
|
||||
printElement(printAreaRef.current, {
|
||||
title: `일일일보_${startDate}`,
|
||||
styles: `
|
||||
.print-container { font-size: 11px; }
|
||||
table { width: 100%; margin-bottom: 12px; }
|
||||
h3 { margin-bottom: 8px; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
}, [startDate]);
|
||||
|
||||
// ===== USD 금액 포맷 =====
|
||||
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
|
||||
|
||||
// ===== 검색 필터링 =====
|
||||
const filteredNoteReceivables = useMemo(() => {
|
||||
@@ -225,6 +239,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
);
|
||||
}, [dailyAccounts, searchTerm]);
|
||||
|
||||
// ===== USD 데이터 존재 여부 =====
|
||||
const hasUsdAccounts = useMemo(() =>
|
||||
filteredDailyAccounts.some(item => item.currency === 'USD'),
|
||||
[filteredDailyAccounts]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
@@ -290,67 +310,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
|
||||
<div className="min-w-[480px] md:min-w-[550px]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredNoteReceivables.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNoteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{filteredNoteReceivables.length > 0 && (
|
||||
<TableFooter className="sticky bottom-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 일자별 상세 */}
|
||||
{/* 인쇄 영역 */}
|
||||
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
|
||||
{/* 일자별 입출금 합계 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
@@ -358,10 +320,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
|
||||
<div className="min-w-[420px] md:min-w-[650px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">구분</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">입금</TableHead>
|
||||
@@ -398,6 +360,35 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* KRW 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">원화(KRW) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{/* USD 계좌들 */}
|
||||
{hasUsdAccounts && filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* USD 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">외국환(USD) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
@@ -412,7 +403,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -424,11 +415,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold">예금 입출금 내역</h3>
|
||||
</div>
|
||||
{/* KRW 입출금 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* 입금 */}
|
||||
{/* KRW 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-blue-700 text-sm md:text-base">입금</span>
|
||||
<span className="font-semibold text-blue-700 text-sm md:text-base">입금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
@@ -474,10 +466,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출금 */}
|
||||
{/* KRW 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-red-700 text-sm md:text-base">출금</span>
|
||||
<span className="font-semibold text-red-700 text-sm md:text-base">출금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
@@ -523,8 +515,165 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
|
||||
{hasUsdAccounts && (
|
||||
<>
|
||||
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold text-emerald-800">외국환(USD) 입출금 내역</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* USD 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-emerald-700 text-sm md:text-base">입금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-emerald-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-orange-700 text-sm md:text-base">출금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-orange-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
|
||||
<div className="min-w-[480px] md:min-w-[550px]">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredNoteReceivables.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNoteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{filteredNoteReceivables.length > 0 && (
|
||||
<TableFooter className="sticky bottom-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,106 @@
|
||||
/**
|
||||
* 상품권 관리 서버 액션 (Mock)
|
||||
* 상품권 관리 서버 액션
|
||||
*
|
||||
* API Endpoints (예정):
|
||||
* - GET /api/v1/gift-certificates - 목록 조회
|
||||
* - GET /api/v1/gift-certificates/{id} - 상세 조회
|
||||
* - POST /api/v1/gift-certificates - 등록
|
||||
* - PUT /api/v1/gift-certificates/{id} - 수정
|
||||
* - DELETE /api/v1/gift-certificates/{id} - 삭제
|
||||
* - GET /api/v1/gift-certificates/summary - 요약 통계
|
||||
* API Endpoints (Loan API 재사용, category='gift_certificate'):
|
||||
* - GET /api/v1/loans?category=gift_certificate - 목록 조회
|
||||
* - GET /api/v1/loans/{id} - 상세 조회
|
||||
* - POST /api/v1/loans - 등록
|
||||
* - PUT /api/v1/loans/{id} - 수정
|
||||
* - DELETE /api/v1/loans/{id} - 삭제
|
||||
* - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import type { ActionResult } from '@/lib/api/execute-server-action';
|
||||
// import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
// import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
GiftCertificateRecord,
|
||||
GiftCertificateFormData,
|
||||
LoanApiData,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToRecord,
|
||||
transformApiToFormData,
|
||||
transformFormToApi,
|
||||
} from './types';
|
||||
|
||||
// ===== 상품권 목록 조회 (Mock) =====
|
||||
export async function getGiftCertificates(_params?: {
|
||||
// ===== 상품권 목록 조회 =====
|
||||
export async function getGiftCertificates(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
}): Promise<ActionResult<GiftCertificateRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: [] };
|
||||
search?: string;
|
||||
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
|
||||
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
|
||||
url: buildApiUrl('/api/v1/loans', {
|
||||
category: 'gift_certificate',
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
search: params?.search,
|
||||
}),
|
||||
transform: transformApiToRecord,
|
||||
errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 상세 조회 (Mock) =====
|
||||
// ===== 상품권 상세 조회 =====
|
||||
export async function getGiftCertificateById(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult<GiftCertificateFormData>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// transform: transformDetailApiToFrontend,
|
||||
// errorMessage: '상품권 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
serialNumber: 'GC-2026-001',
|
||||
name: '신세계 상품권',
|
||||
faceValue: 500000,
|
||||
vendorId: '',
|
||||
vendorName: '신세계백화점',
|
||||
purchaseDate: '2026-02-10',
|
||||
purchasePurpose: 'entertainment',
|
||||
entertainmentExpense: 'applicable',
|
||||
status: 'used',
|
||||
usedDate: '2026-02-20',
|
||||
recipientName: '홍길동',
|
||||
recipientOrganization: '(주)테크솔루션',
|
||||
usageDescription: '거래처 접대용',
|
||||
memo: '2월 접대비 처리 완료',
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
transform: (data: LoanApiData) => transformApiToFormData(data),
|
||||
errorMessage: '상품권 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 등록 (Mock) =====
|
||||
// ===== 상품권 등록 =====
|
||||
export async function createGiftCertificate(
|
||||
_data: GiftCertificateFormData
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates'),
|
||||
// method: 'POST',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 등록에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans'),
|
||||
method: 'POST',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 수정 (Mock) =====
|
||||
// ===== 상품권 수정 =====
|
||||
export async function updateGiftCertificate(
|
||||
_id: string,
|
||||
_data: GiftCertificateFormData
|
||||
id: string,
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'PUT',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 수정에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: _id,
|
||||
serialNumber: _data.serialNumber,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'PUT',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 삭제 (Mock) =====
|
||||
// ===== 상품권 삭제 =====
|
||||
export async function deleteGiftCertificate(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'DELETE',
|
||||
// errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
// });
|
||||
return { success: true };
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 요약 통계 (Mock) =====
|
||||
export async function getGiftCertificateSummary(_params?: {
|
||||
// ===== 상품권 요약 통계 =====
|
||||
export async function getGiftCertificateSummary(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<ActionResult<{
|
||||
@@ -151,23 +113,29 @@ export async function getGiftCertificateSummary(_params?: {
|
||||
entertainmentCount: number;
|
||||
entertainmentAmount: number;
|
||||
}>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
|
||||
// transform: transformSummary,
|
||||
// errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
holdingCount: 0,
|
||||
holdingAmount: 0,
|
||||
usedCount: 0,
|
||||
usedAmount: 0,
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans/summary', {
|
||||
category: 'gift_certificate',
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
}),
|
||||
transform: (data: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
holding_count?: number;
|
||||
holding_amount?: number;
|
||||
used_count?: number;
|
||||
used_amount?: number;
|
||||
}) => ({
|
||||
totalCount: data.total_count ?? 0,
|
||||
totalAmount: data.total_amount ?? 0,
|
||||
holdingCount: data.holding_count ?? 0,
|
||||
holdingAmount: data.holding_amount ?? 0,
|
||||
usedCount: data.used_count ?? 0,
|
||||
usedAmount: data.used_amount ?? 0,
|
||||
entertainmentCount: 0,
|
||||
entertainmentAmount: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,3 +104,94 @@ export function createEmptyFormData(): GiftCertificateFormData {
|
||||
|
||||
// ===== 액면가 50만원 기준 =====
|
||||
export const FACE_VALUE_THRESHOLD = 500000;
|
||||
|
||||
// ===== Loan API 응답 타입 =====
|
||||
export interface LoanApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number | null;
|
||||
loan_date: string;
|
||||
amount: string;
|
||||
purpose: string | null;
|
||||
settlement_date: string | null;
|
||||
settlement_amount: string | null;
|
||||
status: string;
|
||||
category: string | null;
|
||||
metadata: {
|
||||
serial_number?: string;
|
||||
cert_name?: string;
|
||||
vendor_id?: string;
|
||||
vendor_name?: string;
|
||||
purchase_purpose?: string;
|
||||
entertainment_expense?: string;
|
||||
recipient_name?: string;
|
||||
recipient_organization?: string;
|
||||
usage_description?: string;
|
||||
memo?: string;
|
||||
} | null;
|
||||
withdrawal_id: number | null;
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
user?: { id: number; name: string; email: string } | null;
|
||||
creator?: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
// ===== API → 프론트 변환 (목록용) =====
|
||||
export function transformApiToRecord(api: LoanApiData): GiftCertificateRecord {
|
||||
const meta = api.metadata ?? {};
|
||||
return {
|
||||
id: String(api.id),
|
||||
serialNumber: meta.serial_number ?? '',
|
||||
name: meta.cert_name ?? '',
|
||||
faceValue: parseFloat(api.amount) || 0,
|
||||
purchaseDate: api.loan_date ?? '',
|
||||
usedDate: api.settlement_date ?? null,
|
||||
status: (api.status as GiftCertificateStatus) ?? 'holding',
|
||||
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== API → 프론트 변환 (상세/폼용) =====
|
||||
export function transformApiToFormData(api: LoanApiData): GiftCertificateFormData {
|
||||
const meta = api.metadata ?? {};
|
||||
return {
|
||||
serialNumber: meta.serial_number ?? '',
|
||||
name: meta.cert_name ?? '',
|
||||
faceValue: parseFloat(api.amount) || 0,
|
||||
vendorId: meta.vendor_id ?? '',
|
||||
vendorName: meta.vendor_name ?? '',
|
||||
purchaseDate: api.loan_date ?? '',
|
||||
purchasePurpose: (meta.purchase_purpose as PurchasePurpose) ?? 'promotion',
|
||||
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
|
||||
status: (api.status as GiftCertificateStatus) ?? 'holding',
|
||||
usedDate: api.settlement_date ?? '',
|
||||
recipientName: meta.recipient_name ?? '',
|
||||
recipientOrganization: meta.recipient_organization ?? '',
|
||||
usageDescription: meta.usage_description ?? '',
|
||||
memo: meta.memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 프론트 → API 변환 =====
|
||||
export function transformFormToApi(data: GiftCertificateFormData): Record<string, unknown> {
|
||||
return {
|
||||
loan_date: data.purchaseDate,
|
||||
amount: data.faceValue,
|
||||
purpose: data.usageDescription || null,
|
||||
category: 'gift_certificate',
|
||||
status: data.status,
|
||||
settlement_date: data.usedDate || null,
|
||||
metadata: {
|
||||
serial_number: data.serialNumber || null,
|
||||
cert_name: data.name || null,
|
||||
vendor_id: data.vendorId || null,
|
||||
vendor_name: data.vendorName || null,
|
||||
purchase_purpose: data.purchasePurpose || null,
|
||||
entertainment_expense: data.entertainmentExpense || null,
|
||||
recipient_name: data.recipientName || null,
|
||||
recipient_organization: data.recipientOrganization || null,
|
||||
usage_description: data.usageDescription || null,
|
||||
memo: data.memo || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ import {
|
||||
CATEGORY_LABELS,
|
||||
SORT_OPTIONS,
|
||||
} from './types';
|
||||
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
|
||||
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { filterByText } from '@/lib/utils/search';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 엑셀 다운로드 핸들러 =====
|
||||
// ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportReceivablesExcel({
|
||||
year: selectedYear,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || '채권현황.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
// 데이터가 이미 로드되어 있으므로 sortedData 사용
|
||||
if (sortedData.length === 0) {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
// 동적 월 컬럼 포함 엑셀 컬럼 생성
|
||||
const columns: ExcelColumn<Record<string, unknown>>[] = [
|
||||
{ header: '거래처', key: 'vendorName', width: 20 },
|
||||
{ header: '연체', key: 'isOverdue', width: 8 },
|
||||
...monthLabels.map((label, idx) => ({
|
||||
header: label, key: `month_${idx}`, width: 12,
|
||||
})),
|
||||
{ header: '합계', key: 'total', width: 14 },
|
||||
{ header: '메모', key: 'memo', width: 20 },
|
||||
];
|
||||
// 미수금 카테고리 기준으로 플랫 데이터 생성
|
||||
const exportData = sortedData.map(vendor => {
|
||||
const receivable = vendor.categories.find(c => c.category === 'receivable');
|
||||
const row: Record<string, unknown> = {
|
||||
vendorName: vendor.vendorName,
|
||||
isOverdue: vendor.isOverdue ? '연체' : '',
|
||||
};
|
||||
monthLabels.forEach((_, idx) => {
|
||||
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
|
||||
});
|
||||
row.total = receivable?.amounts.total || 0;
|
||||
row.memo = vendor.memo || '';
|
||||
return row;
|
||||
});
|
||||
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [selectedYear, searchQuery]);
|
||||
}, [sortedData, monthLabels]);
|
||||
|
||||
// ===== 변경된 연체 항목 확인 =====
|
||||
const changedOverdueItems = useMemo(() => {
|
||||
|
||||
@@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import {
|
||||
getTaxInvoices,
|
||||
getTaxInvoiceSummary,
|
||||
downloadTaxInvoiceExcel,
|
||||
} from './actions';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
const ManualEntryModal = dynamic(
|
||||
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
|
||||
@@ -58,6 +58,10 @@ import type {
|
||||
TaxInvoiceMgmtRecord,
|
||||
InvoiceTab,
|
||||
TaxInvoiceSummary,
|
||||
TaxType,
|
||||
ReceiptType,
|
||||
InvoiceStatus,
|
||||
InvoiceSource,
|
||||
} from './types';
|
||||
import {
|
||||
TAB_OPTIONS,
|
||||
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
|
||||
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
|
||||
];
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
|
||||
{ header: '작성일자', key: 'writeDate', width: 12 },
|
||||
{ header: '발급일자', key: 'issueDate', width: 12 },
|
||||
{ header: '거래처', key: 'vendorName', width: 20 },
|
||||
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
|
||||
{ header: '과세형태', key: 'taxType', width: 10,
|
||||
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
|
||||
{ header: '품목', key: 'itemName', width: 15 },
|
||||
{ header: '공급가액', key: 'supplyAmount', width: 14 },
|
||||
{ header: '세액', key: 'taxAmount', width: 14 },
|
||||
{ header: '합계', key: 'totalAmount', width: 14 },
|
||||
{ header: '영수청구', key: 'receiptType', width: 10,
|
||||
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
|
||||
{ header: '상태', key: 'status', width: 10,
|
||||
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
|
||||
{ header: '발급형태', key: 'source', width: 10,
|
||||
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
|
||||
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
// ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await downloadTaxInvoiceExcel({
|
||||
division: activeTab,
|
||||
dateType,
|
||||
startDate,
|
||||
endDate,
|
||||
vendorSearch,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.url, '_blank');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: TaxInvoiceMgmtRecord[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getTaxInvoices({
|
||||
division: activeTab,
|
||||
dateType,
|
||||
startDate,
|
||||
endDate,
|
||||
vendorSearch,
|
||||
page,
|
||||
perPage: 100,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel({
|
||||
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
|
||||
sheetName: activeTab === 'sales' ? '매출' : '매입',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [activeTab, dateType, startDate, endDate, vendorSearch]);
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
|
||||
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
|
||||
import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
@@ -43,6 +44,16 @@ const tableColumns = [
|
||||
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
];
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
|
||||
{ header: '거래처명', key: 'vendorName', width: 20 },
|
||||
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
|
||||
{ header: '매출', key: 'sales', width: 14 },
|
||||
{ header: '수금', key: 'collection', width: 14 },
|
||||
{ header: '잔액', key: 'balance', width: 14 },
|
||||
{ header: '결제일', key: 'paymentDate', width: 12 },
|
||||
];
|
||||
|
||||
// ===== Props =====
|
||||
interface VendorLedgerProps {
|
||||
initialData?: VendorLedgerItem[];
|
||||
@@ -144,24 +155,42 @@ export function VendorLedger({
|
||||
);
|
||||
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportVendorLedgerExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: VendorLedgerItem[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
if (result.success && result.data) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || '거래처원장.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
do {
|
||||
const result = await getVendorLedgerList({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
|
||||
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: '거래처원장',
|
||||
sheetName: '거래처원장',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, searchQuery]);
|
||||
|
||||
|
||||
@@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
|
||||
import { vendorConfig } from './vendorConfig';
|
||||
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
businessNumber: '사업자등록번호',
|
||||
vendorName: '거래처명',
|
||||
category: '거래처 유형',
|
||||
};
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +23,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
// 새 입력 컴포넌트
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
@@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [formData.businessNumber, formData.vendorName, formData.category]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: string, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 에러 클리어
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 파일 검증 및 추가
|
||||
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
|
||||
@@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 저장 핸들러 (IntegratedDetailTemplate용)
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return { success: false, error: '입력 내용을 확인해주세요.' };
|
||||
}
|
||||
|
||||
@@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={isViewMode || disabled}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onValueChange={(val) => handleChange(field, val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 폼 콘텐츠 렌더링 (View/Edit 공통)
|
||||
const renderFormContent = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
showValidation={!isViewMode}
|
||||
error={!!validationErrors.businessNumber}
|
||||
/>
|
||||
{validationErrors.businessNumber && <p className="text-sm text-red-500">{validationErrors.businessNumber}</p>}
|
||||
</div>
|
||||
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
|
||||
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
|
||||
|
||||
@@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
||||
enumFilter('creditRating', creditRatingFilter),
|
||||
enumFilter('transactionGrade', transactionGradeFilter),
|
||||
enumFilter('badDebtStatus', badDebtFilter),
|
||||
(items: Vendor[]) => items.filter((item) => {
|
||||
if (!item.createdAt) return true;
|
||||
const created = item.createdAt.slice(0, 10);
|
||||
return created >= startDate && created <= endDate;
|
||||
}),
|
||||
]);
|
||||
|
||||
// 정렬
|
||||
@@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
|
||||
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
@@ -131,6 +131,8 @@ export async function getClients(params?: {
|
||||
size?: number;
|
||||
q?: string;
|
||||
only_active?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', {
|
||||
@@ -138,6 +140,8 @@ export async function getClients(params?: {
|
||||
size: params?.size,
|
||||
q: params?.q,
|
||||
only_active: params?.only_active,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
transform: (data: PaginatedResponse<ClientApiData>) => ({
|
||||
items: data.data.map(transformApiToFrontend),
|
||||
|
||||
@@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
dateField: 'createdAt',
|
||||
},
|
||||
|
||||
// 데이터 변경 콜백 (Stats 계산용)
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -138,7 +137,7 @@ export function ShipmentCreate() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
@@ -226,7 +225,9 @@ export function ShipmentCreate() {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
@@ -245,7 +246,13 @@ export function ShipmentCreate() {
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
@@ -289,12 +296,16 @@ export function ShipmentCreate() {
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
if (!formData.lotNo) errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -349,30 +360,6 @@ export function ShipmentCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -393,7 +380,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,6 +391,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
@@ -432,7 +420,9 @@ export function ShipmentCreate() {
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
@@ -449,7 +439,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -460,6 +450,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
@@ -748,9 +739,7 @@ export function ShipmentCreate() {
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">{error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
|
||||
@@ -391,7 +391,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ShipmentList() {
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('week-time');
|
||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||
|
||||
// startDate 변경 시 캘린더 월 자동 이동
|
||||
|
||||
@@ -50,6 +50,9 @@ interface OrderInfoApiData {
|
||||
site_name?: string;
|
||||
delivery_address?: string;
|
||||
contact?: string;
|
||||
delivery_date?: string;
|
||||
writer_id?: number;
|
||||
writer_name?: string;
|
||||
}
|
||||
|
||||
interface ShipmentApiData {
|
||||
@@ -91,6 +94,16 @@ interface ShipmentApiData {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
items?: ShipmentItemApiData[];
|
||||
vehicle_dispatches?: Array<{
|
||||
id: number;
|
||||
seq: number;
|
||||
logistics_company?: string;
|
||||
arrival_datetime?: string;
|
||||
tonnage?: string;
|
||||
vehicle_no?: string;
|
||||
driver_contact?: string;
|
||||
remarks?: string;
|
||||
}>;
|
||||
status_label?: string;
|
||||
priority_label?: string;
|
||||
delivery_method_label?: string;
|
||||
@@ -146,7 +159,13 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
|
||||
canShip: data.can_ship,
|
||||
depositConfirmed: data.deposit_confirmed,
|
||||
invoiceIssued: data.invoice_issued,
|
||||
deliveryTime: data.expected_arrival,
|
||||
deliveryTime: data.vehicle_dispatches?.[0]?.arrival_datetime || data.expected_arrival,
|
||||
// 수신/작성자/출고일 매핑
|
||||
receiver: data.receiver || '',
|
||||
receiverAddress: data.order_info?.delivery_address || data.delivery_address || '',
|
||||
receiverCompany: data.order_info?.customer_name || data.customer_name || '',
|
||||
writer: data.order_info?.writer_name || data.creator?.name || '',
|
||||
shipmentDate: data.scheduled_date || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,18 +212,28 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
zipCode: (data as unknown as Record<string, unknown>).zip_code as string | undefined,
|
||||
address: (data as unknown as Record<string, unknown>).address as string | undefined,
|
||||
addressDetail: (data as unknown as Record<string, unknown>).address_detail as string | undefined,
|
||||
// 배차 정보 - 기존 단일 필드에서 구성 (다중 행 API 준비 전까지)
|
||||
vehicleDispatches: data.vehicle_no || data.logistics_company || data.driver_contact
|
||||
? [{
|
||||
id: `vd-${data.id}`,
|
||||
logisticsCompany: data.logistics_company || '-',
|
||||
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
|
||||
tonnage: data.vehicle_tonnage || '-',
|
||||
vehicleNo: data.vehicle_no || '-',
|
||||
driverContact: data.driver_contact || '-',
|
||||
remarks: '',
|
||||
}]
|
||||
: [],
|
||||
// 배차 정보 - vehicle_dispatches 테이블에서 조회, 없으면 레거시 단일 필드 fallback
|
||||
vehicleDispatches: data.vehicle_dispatches && data.vehicle_dispatches.length > 0
|
||||
? data.vehicle_dispatches.map((vd) => ({
|
||||
id: String(vd.id),
|
||||
logisticsCompany: vd.logistics_company || '-',
|
||||
arrivalDateTime: vd.arrival_datetime || '-',
|
||||
tonnage: vd.tonnage || '-',
|
||||
vehicleNo: vd.vehicle_no || '-',
|
||||
driverContact: vd.driver_contact || '-',
|
||||
remarks: vd.remarks || '',
|
||||
}))
|
||||
: (data.vehicle_no || data.logistics_company || data.driver_contact
|
||||
? [{
|
||||
id: `vd-legacy-${data.id}`,
|
||||
logisticsCompany: data.logistics_company || '-',
|
||||
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
|
||||
tonnage: data.vehicle_tonnage || '-',
|
||||
vehicleNo: data.vehicle_no || '-',
|
||||
driverContact: data.driver_contact || '-',
|
||||
remarks: '',
|
||||
}]
|
||||
: []),
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
otherParts: [],
|
||||
@@ -256,7 +285,7 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh
|
||||
function transformCreateFormToApi(
|
||||
data: ShipmentCreateFormData
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
const result: Record<string, unknown> = {
|
||||
lot_no: data.lotNo,
|
||||
scheduled_date: data.scheduledDate,
|
||||
priority: data.priority,
|
||||
@@ -267,6 +296,20 @@ function transformCreateFormToApi(
|
||||
loading_manager: data.loadingManager,
|
||||
remarks: data.remarks,
|
||||
};
|
||||
|
||||
if (data.vehicleDispatches && data.vehicleDispatches.length > 0) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (수정용) =====
|
||||
@@ -278,6 +321,17 @@ function transformEditFormToApi(
|
||||
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
|
||||
if (data.priority !== undefined) result.priority = data.priority;
|
||||
if (data.deliveryMethod !== undefined) result.delivery_method = data.deliveryMethod;
|
||||
if (data.receiver !== undefined) result.receiver = data.receiver;
|
||||
if (data.receiverContact !== undefined) result.receiver_contact = data.receiverContact;
|
||||
// 주소: zipCode + address + addressDetail → delivery_address로 결합
|
||||
if (data.address !== undefined || data.zipCode !== undefined || data.addressDetail !== undefined) {
|
||||
const parts = [
|
||||
data.zipCode ? `[${data.zipCode}]` : '',
|
||||
data.address || '',
|
||||
data.addressDetail || '',
|
||||
].filter(Boolean);
|
||||
result.delivery_address = parts.join(' ');
|
||||
}
|
||||
if (data.loadingManager !== undefined) result.loading_manager = data.loadingManager;
|
||||
if (data.logisticsCompany !== undefined) result.logistics_company = data.logisticsCompany;
|
||||
if (data.vehicleTonnage !== undefined) result.vehicle_tonnage = data.vehicleTonnage;
|
||||
@@ -287,8 +341,21 @@ function transformEditFormToApi(
|
||||
if (data.driverContact !== undefined) result.driver_contact = data.driverContact;
|
||||
if (data.expectedArrival !== undefined) result.expected_arrival = data.expectedArrival;
|
||||
if (data.confirmedArrival !== undefined) result.confirmed_arrival = data.confirmedArrival;
|
||||
if (data.changeReason !== undefined) result.change_reason = data.changeReason;
|
||||
if (data.remarks !== undefined) result.remarks = data.remarks;
|
||||
|
||||
if (data.vehicleDispatches) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,6 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
@@ -111,34 +103,20 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 (테이블 형태) */}
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>{detail.logisticsCompany || '-'}</TableCell>
|
||||
<TableCell>{detail.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell>{detail.tonnage || '-'}</TableCell>
|
||||
<TableCell>{detail.vehicleNo || '-'}</TableCell>
|
||||
<TableCell>{detail.driverContact || '-'}</TableCell>
|
||||
<TableCell>{detail.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('물류업체', detail.logisticsCompany)}
|
||||
{renderInfoField('입차일시', detail.arrivalDateTime)}
|
||||
{renderInfoField('구분', detail.tonnage)}
|
||||
{renderInfoField('차량번호', detail.vehicleNo)}
|
||||
{renderInfoField('기사연락처', detail.driverContact)}
|
||||
{renderInfoField('비고', detail.remarks)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -70,7 +69,7 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -121,13 +120,13 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
vat,
|
||||
totalAmount: total,
|
||||
}));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
}, [validationErrors]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -177,19 +176,6 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 (운임비용만 편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -370,11 +356,9 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
mode: string;
|
||||
errors: Record<string, string>;
|
||||
}) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* 배차차량관리 서버 액션
|
||||
*
|
||||
* 현재: Mock 데이터 반환
|
||||
* 추후: API 연동 시 serverFetch 사용
|
||||
*/
|
||||
|
||||
'use server';
|
||||
@@ -13,11 +10,9 @@ import type {
|
||||
VehicleDispatchStats,
|
||||
VehicleDispatchEditFormData,
|
||||
} from './types';
|
||||
import {
|
||||
mockVehicleDispatchItems,
|
||||
mockVehicleDispatchDetail,
|
||||
mockVehicleDispatchStats,
|
||||
} from './mockData';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
|
||||
// ===== 페이지네이션 타입 =====
|
||||
interface PaginationMeta {
|
||||
@@ -27,6 +22,59 @@ interface PaginationMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== API 응답 → 프론트 타입 변환 =====
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToListItem(data: any): VehicleDispatchItem {
|
||||
const options = data.options || {};
|
||||
const shipment = data.shipment || {};
|
||||
return {
|
||||
id: String(data.id),
|
||||
dispatchNo: options.dispatch_no || `DC-${data.id}`,
|
||||
shipmentNo: shipment.shipment_no || '',
|
||||
lotNo: shipment.lot_no || '',
|
||||
siteName: shipment.site_name || '',
|
||||
orderCustomer: shipment.customer_name || '',
|
||||
logisticsCompany: data.logistics_company || '',
|
||||
tonnage: data.tonnage || '',
|
||||
supplyAmount: options.supply_amount || 0,
|
||||
vat: options.vat || 0,
|
||||
totalAmount: options.total_amount || 0,
|
||||
freightCostType: options.freight_cost_type || 'prepaid',
|
||||
vehicleNo: data.vehicle_no || '',
|
||||
driverContact: data.driver_contact || '',
|
||||
writer: options.writer || '',
|
||||
arrivalDateTime: data.arrival_datetime || '',
|
||||
status: options.status || 'draft',
|
||||
remarks: data.remarks || '',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToDetail(data: any): VehicleDispatchDetail {
|
||||
const options = data.options || {};
|
||||
const shipment = data.shipment || {};
|
||||
return {
|
||||
id: String(data.id),
|
||||
dispatchNo: options.dispatch_no || `DC-${data.id}`,
|
||||
shipmentNo: shipment.shipment_no || '',
|
||||
lotNo: shipment.lot_no || '',
|
||||
siteName: shipment.site_name || '',
|
||||
orderCustomer: shipment.customer_name || '',
|
||||
freightCostType: options.freight_cost_type || 'prepaid',
|
||||
status: options.status || 'draft',
|
||||
writer: options.writer || '',
|
||||
logisticsCompany: data.logistics_company || '',
|
||||
arrivalDateTime: data.arrival_datetime || '',
|
||||
tonnage: data.tonnage || '',
|
||||
vehicleNo: data.vehicle_no || '',
|
||||
driverContact: data.driver_contact || '',
|
||||
remarks: data.remarks || '',
|
||||
supplyAmount: options.supply_amount || 0,
|
||||
vat: options.vat || 0,
|
||||
totalAmount: options.total_amount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 배차차량 목록 조회 =====
|
||||
export async function getVehicleDispatches(params?: {
|
||||
page?: number;
|
||||
@@ -41,54 +89,18 @@ export async function getVehicleDispatches(params?: {
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let items = [...mockVehicleDispatchItems];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const perPage = params?.perPage || 20;
|
||||
const total = items.length;
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const paginatedItems = items.slice(startIndex, startIndex + perPage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: paginatedItems,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
lastPage,
|
||||
perPage,
|
||||
total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatches error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
return executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches', {
|
||||
search: params?.search,
|
||||
status: params?.status !== 'all' ? params?.status : undefined,
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
}),
|
||||
transform: transformToListItem,
|
||||
errorMessage: '배차차량 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
@@ -97,12 +109,18 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, data: mockVehicleDispatchStats };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction<
|
||||
{ prepaid_amount: number; collect_amount: number; total_amount: number },
|
||||
VehicleDispatchStats
|
||||
>({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches/stats'),
|
||||
transform: (data) => ({
|
||||
prepaidAmount: data.prepaid_amount,
|
||||
collectAmount: data.collect_amount,
|
||||
totalAmount: data.total_amount,
|
||||
}),
|
||||
errorMessage: '배차차량 통계 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
@@ -111,51 +129,34 @@ export async function getVehicleDispatchById(id: string): Promise<{
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Mock: ID로 목록에서 찾아서 상세 데이터 생성
|
||||
const item = mockVehicleDispatchItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
// fallback으로 기본 상세 데이터 반환
|
||||
return { success: true, data: { ...mockVehicleDispatchDetail, id } };
|
||||
}
|
||||
|
||||
const detail: VehicleDispatchDetail = {
|
||||
id: item.id,
|
||||
dispatchNo: item.dispatchNo,
|
||||
shipmentNo: item.shipmentNo,
|
||||
siteName: item.siteName,
|
||||
orderCustomer: item.orderCustomer,
|
||||
freightCostType: item.freightCostType,
|
||||
status: item.status,
|
||||
writer: item.writer,
|
||||
logisticsCompany: item.logisticsCompany,
|
||||
arrivalDateTime: item.arrivalDateTime,
|
||||
tonnage: item.tonnage,
|
||||
vehicleNo: item.vehicleNo,
|
||||
driverContact: item.driverContact,
|
||||
remarks: item.remarks,
|
||||
supplyAmount: item.supplyAmount,
|
||||
vat: item.vat,
|
||||
totalAmount: item.totalAmount,
|
||||
};
|
||||
|
||||
return { success: true, data: detail };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
transform: transformToDetail,
|
||||
errorMessage: '배차차량 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 수정 =====
|
||||
export async function updateVehicleDispatch(
|
||||
id: string,
|
||||
_data: VehicleDispatchEditFormData
|
||||
data: VehicleDispatchEditFormData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Mock: 항상 성공 반환
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] updateVehicleDispatch error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
method: 'PUT',
|
||||
body: {
|
||||
freight_cost_type: data.freightCostType,
|
||||
logistics_company: data.logisticsCompany,
|
||||
arrival_datetime: data.arrivalDateTime,
|
||||
tonnage: data.tonnage,
|
||||
vehicle_no: data.vehicleNo,
|
||||
driver_contact: data.driverContact,
|
||||
remarks: data.remarks,
|
||||
supply_amount: data.supplyAmount,
|
||||
vat: data.vat,
|
||||
total_amount: data.totalAmount,
|
||||
status: undefined, // 상태는 별도로 관리
|
||||
},
|
||||
errorMessage: '배차차량 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user