feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 (104 files) - 생산대시보드/작업지시 모바일 호환성 강화 - 견적서/주문관리 반응형 그리드 적용 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { useState, useCallback, useMemo } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { Plus, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@@ -913,7 +913,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDeleteMemo(memo.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -281,7 +281,7 @@ export function TransactionFormModal({
|
||||
</div>
|
||||
|
||||
{/* 거래일 * / 거래시간 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
거래일 <span className="text-red-500">*</span>
|
||||
@@ -327,7 +327,7 @@ export function TransactionFormModal({
|
||||
</div>
|
||||
|
||||
{/* 금액 * / 잔액 (자동계산) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
@@ -399,33 +399,15 @@ export function TransactionFormModal({
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
{/* 좌측: 원본으로 복원 (③) - 수정 모드에서만 */}
|
||||
<div>
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestore}
|
||||
disabled={isProcessing}
|
||||
className="gap-1"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
원본으로 복원
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 삭제 + 수정/등록 */}
|
||||
<div className="flex flex-col gap-2 pt-4 border-t">
|
||||
{/* 삭제 + 수정/등록 (50:50) */}
|
||||
<div className="flex gap-2">
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
삭제
|
||||
@@ -434,11 +416,29 @@ export function TransactionFormModal({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
{mode === 'create' ? '등록' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 원본으로 복원 (100%) - 수정 모드에서만 */}
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestore}
|
||||
disabled={isProcessing}
|
||||
className="w-full gap-1"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
원본으로 복원
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
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';
|
||||
@@ -428,13 +428,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead>일자</TableHead>
|
||||
<TableHead>금액</TableHead>
|
||||
<TableHead>비고</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>
|
||||
@@ -480,7 +481,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -489,6 +490,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -247,7 +247,7 @@ export function BillManagementClient({
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<InfoField label="거래처" value={item.vendorName} />
|
||||
<InfoField label="금액" value={`${formatNumber(item.amount)}원`} />
|
||||
<InfoField label="발행일" value={item.issueDate} />
|
||||
@@ -387,35 +387,39 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 상태 선택 + 저장 + 수취/발행 라디오
|
||||
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
|
||||
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
|
||||
// 데스크톱: 모두 표시
|
||||
headerActions: () => (
|
||||
<div className="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">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer text-sm">발행</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 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" />
|
||||
저장
|
||||
|
||||
@@ -140,7 +140,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 사용일 + 사용시간 (공통 DatePicker/TimePicker) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
사용일 <span className="text-red-500">*</span>
|
||||
@@ -167,7 +167,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 승인번호 + 승인유형 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="승인번호"
|
||||
value={formData.approvalNumber}
|
||||
@@ -196,7 +196,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 공급가액 + 세액 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="number"
|
||||
label="공급가액"
|
||||
@@ -216,7 +216,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 가맹점명 + 사업자번호 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="가맹점명"
|
||||
value={formData.merchantName}
|
||||
@@ -233,7 +233,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 공제여부 + 계정과목 (Select - FormField 예외) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
공제여부 <span className="text-red-500">*</span>
|
||||
@@ -272,7 +272,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
|
||||
{/* 증빙/판매자상호 + 내역 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="증빙/판매자상호"
|
||||
value={formData.vendorName}
|
||||
@@ -297,7 +297,7 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
/>
|
||||
|
||||
{/* 합계 금액 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="bg-muted/50 rounded-lg p-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-sm font-medium">합계 금액 (공급가액 + 세액)</span>
|
||||
<span className="text-lg font-bold">{formatNumber(totalAmount)}원</span>
|
||||
</div>
|
||||
|
||||
@@ -104,42 +104,3 @@ export async function getDailyReportSummary(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 일일 보고서 엑셀 다운로드 =====
|
||||
export async function exportDailyReportExcel(params?: {
|
||||
date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/api/v1/daily-report/export', { date: params?.date }),
|
||||
{ method: 'GET', headers }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${params?.date || 'today'}.xlsx`;
|
||||
|
||||
return { success: true, data: blob, filename };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DailyReportActions] exportDailyReportExcel error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary, exportDailyReportExcel } from './actions';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
@@ -156,22 +156,32 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportDailyReportExcel({ date: selectedDate });
|
||||
try {
|
||||
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
if (!response.ok) {
|
||||
toast.error(`엑셀 다운로드에 실패했습니다. (${response.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || `일일일보_${selectedDate}.xlsx`;
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
@@ -187,18 +197,17 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">조회 일자</span>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">조회 일자</span>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-auto min-w-[140px]"
|
||||
size="sm"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -206,18 +215,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
엑셀 다운로드
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -232,13 +242,14 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<h3 className="text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[550px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right w-[150px]">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center w-[120px]">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center w-[120px]">만기일</TableHead>
|
||||
<TableHead className="font-semibold max-w-[200px]">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -260,10 +271,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
) : (
|
||||
noteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell>{item.content}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center">{item.dueDate}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -272,13 +283,14 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableFooter>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -292,15 +304,16 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[650px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold w-[200px]">구분</TableHead>
|
||||
<TableHead className="font-semibold text-center w-[80px]">상태</TableHead>
|
||||
<TableHead className="font-semibold text-right w-[150px]">전월 이월</TableHead>
|
||||
<TableHead className="font-semibold text-right w-[120px]">수입</TableHead>
|
||||
<TableHead className="font-semibold text-right w-[120px]">지출</TableHead>
|
||||
<TableHead className="font-semibold text-right w-[120px]">잔액</TableHead>
|
||||
<TableHead className="font-semibold max-w-[180px]">구분</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">전월 이월</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">수입</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">지출</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -326,16 +339,16 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
.filter(item => item.currency === 'KRW')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell>{item.category}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">
|
||||
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
|
||||
{MATCH_STATUS_LABELS[item.matchStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.carryover)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.balance)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
@@ -345,25 +358,26 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableFooter>
|
||||
{/* 외화원 (USD) 합계 */}
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-semibold">외화원 (USD) 합계</TableCell>
|
||||
<TableCell className="font-semibold whitespace-nowrap">외화원 (USD) 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right">${formatAmount(accountTotals.usd.carryover)}</TableCell>
|
||||
<TableCell className="text-right">${formatAmount(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right">${formatAmount(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right">${formatAmount(accountTotals.usd.balance)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
{/* 현금성 자산 합계 */}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold">현금성 자산 합계</TableCell>
|
||||
<TableCell className="font-bold whitespace-nowrap">현금성 자산 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(cashAssetTotal.carryover)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -765,11 +765,18 @@ export function ExpectedExpenseManagement({
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="예상 지급일" value={item.expectedPaymentDate} />
|
||||
<InfoField label="지출금액" value={`${formatNumber(item.amount)}원`} />
|
||||
<InfoField label="거래처" value={item.vendorName} />
|
||||
<InfoField label="계좌" value={item.bankAccount} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{[
|
||||
{ label: '예상 지급일', value: item.expectedPaymentDate },
|
||||
{ label: '지출금액', value: `${formatNumber(item.amount)}원` },
|
||||
{ label: '거래처', value: item.vendorName },
|
||||
{ label: '계좌', value: item.bankAccount || '-' },
|
||||
].map((field) => (
|
||||
<div key={field.label} className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{field.label}</span>
|
||||
<span className="font-medium">{field.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -1080,13 +1087,13 @@ export function ExpectedExpenseManagement({
|
||||
|
||||
{/* 등록/수정 폼 다이얼로그 */}
|
||||
<Dialog open={showFormDialog} onOpenChange={setShowFormDialog}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? '미지급비용 수정' : '미지급비용 등록'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1">
|
||||
{/* 예상 지급일 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>예상 지급일 *</Label>
|
||||
<DatePicker
|
||||
@@ -1118,7 +1125,7 @@ export function ExpectedExpenseManagement({
|
||||
</div>
|
||||
|
||||
{/* 거래처 / 금액 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
<Select
|
||||
@@ -1156,7 +1163,7 @@ export function ExpectedExpenseManagement({
|
||||
</div>
|
||||
|
||||
{/* 계좌 / 계정과목 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출금계좌</Label>
|
||||
<Select
|
||||
@@ -1203,7 +1210,7 @@ export function ExpectedExpenseManagement({
|
||||
value={formData.paymentStatus}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, paymentStatus: value as PaymentStatus }))}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px] w-auto">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="결제상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -205,74 +205,78 @@ export function AccountSubjectSettingModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* 추가 영역 */}
|
||||
<div className="flex items-end gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<FormField
|
||||
label="코드"
|
||||
value={newCode}
|
||||
onChange={setNewCode}
|
||||
placeholder="코드"
|
||||
className="flex-1"
|
||||
<div className="space-y-2 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<FormField
|
||||
label="코드"
|
||||
value={newCode}
|
||||
onChange={setNewCode}
|
||||
placeholder="코드"
|
||||
/>
|
||||
<FormField
|
||||
label="계정과목명"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
placeholder="계정과목명"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1.5 block">분류</label>
|
||||
<Select
|
||||
value={newCategory}
|
||||
onValueChange={(v) => setNewCategory(v as AccountSubjectCategory)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={handleAdd} disabled={isAdding}>
|
||||
{isAdding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색/필터 영역 */}
|
||||
<div className="space-y-2 sm:space-y-0 sm:flex sm:items-center sm:gap-2">
|
||||
<Input
|
||||
placeholder="코드 또는 이름 검색"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full sm:max-w-[250px] h-9 text-sm"
|
||||
/>
|
||||
<FormField
|
||||
label="계정과목명"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
placeholder="계정과목명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1.5 block">분류</label>
|
||||
<Select
|
||||
value={newCategory}
|
||||
onValueChange={(v) => setNewCategory(v as AccountSubjectCategory)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredSubjects.length}개
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={handleAdd} disabled={isAdding}>
|
||||
{isAdding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색/필터 영역 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="코드 또는 이름 검색"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-[250px] h-9 text-sm"
|
||||
/>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredSubjects.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
|
||||
@@ -278,7 +278,7 @@ export function JournalEditModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* 거래 정보 (읽기전용) */}
|
||||
<div className="grid grid-cols-5 gap-3 p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">날짜</Label>
|
||||
<div className="font-medium">{record.date}</div>
|
||||
@@ -323,13 +323,13 @@ export function JournalEditModal({
|
||||
</div>
|
||||
|
||||
{/* 분개 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<div className="flex-1 min-h-0 max-h-[40vh] overflow-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-[150px] text-sm text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Table className="min-w-[750px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center w-[90px]">구분</TableHead>
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ManualJournalEntryModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* 거래 정보 */}
|
||||
<div className="grid grid-cols-3 gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
전표일자 <span className="text-red-500">*</span>
|
||||
@@ -239,8 +239,8 @@ export function ManualJournalEntryModal({
|
||||
</div>
|
||||
|
||||
{/* 분개 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<div className="flex-1 min-h-0 max-h-[40vh] overflow-auto border rounded-md">
|
||||
<Table className="min-w-[750px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center w-[90px]">구분</TableHead>
|
||||
|
||||
@@ -316,8 +316,6 @@ export function GeneralJournalEntry() {
|
||||
subtitle={item.date}
|
||||
badge={JOURNAL_DIVISION_LABELS[item.division] || item.division}
|
||||
badgeVariant="outline"
|
||||
isSelected={false}
|
||||
onToggle={() => {}}
|
||||
onClick={() => setJournalEditTarget(item)}
|
||||
details={[
|
||||
{ label: '입금', value: `${formatNumber(item.depositAmount || 0)}원` },
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { PageLayout, PageHeader } from '@/components/organisms';
|
||||
import { DetailActions } from '@/components/templates/IntegratedDetailTemplate/components/DetailActions';
|
||||
import { Gift, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import type { GiftCertificateFormData } from './types';
|
||||
|
||||
interface GiftCertificateDetailProps {
|
||||
mode: 'new' | 'edit';
|
||||
mode: 'new' | 'edit' | 'view';
|
||||
initialData?: GiftCertificateFormData;
|
||||
id?: string;
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export function GiftCertificateDetail({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isNew = mode === 'new';
|
||||
const isView = mode === 'view';
|
||||
const isEditable = !isView;
|
||||
const showUsageInfo = formData.faceValue >= FACE_VALUE_THRESHOLD;
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -110,11 +112,13 @@ export function GiftCertificateDetail({
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isNew ? '상품권 등록' : '상품권 상세'}
|
||||
title={isNew ? '상품권 등록' : isView ? '상품권 상세' : '상품권 수정'}
|
||||
description={
|
||||
isNew
|
||||
? '새 상품권을 등록합니다.'
|
||||
: '상품권 상세를 등록하고 관리합니다.'
|
||||
: isView
|
||||
? '상품권 상세 정보를 확인합니다.'
|
||||
: '상품권 정보를 수정합니다.'
|
||||
}
|
||||
icon={Gift}
|
||||
/>
|
||||
@@ -135,21 +139,23 @@ export function GiftCertificateDetail({
|
||||
/>
|
||||
<FormField
|
||||
label="상품권명"
|
||||
required
|
||||
required={isEditable}
|
||||
value={formData.name}
|
||||
onChange={(v) => handleChange('name', v)}
|
||||
placeholder="상품권명을 입력하세요"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="액면가"
|
||||
required
|
||||
required={isEditable}
|
||||
type="currency"
|
||||
value={formData.faceValue}
|
||||
onChangeNumber={(v) => handleChange('faceValue', v ?? 0)}
|
||||
placeholder="0"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="구입처"
|
||||
@@ -159,16 +165,18 @@ export function GiftCertificateDetail({
|
||||
options={[]}
|
||||
selectPlaceholder="거래처 선택"
|
||||
helpText="매입 거래처명 목록 (API 연동 후 사용 가능)"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="구입일"
|
||||
required
|
||||
required={isEditable}
|
||||
type="date"
|
||||
value={formData.purchaseDate}
|
||||
onChange={(v) => handleChange('purchaseDate', v)}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="구입목적"
|
||||
@@ -176,6 +184,7 @@ export function GiftCertificateDetail({
|
||||
value={formData.purchasePurpose}
|
||||
onChange={(v) => handleChange('purchasePurpose', v)}
|
||||
options={PURCHASE_PURPOSE_OPTIONS}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -186,6 +195,7 @@ export function GiftCertificateDetail({
|
||||
value={formData.entertainmentExpense}
|
||||
onChange={(v) => handleChange('entertainmentExpense', v)}
|
||||
options={ENTERTAINMENT_EXPENSE_OPTIONS}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="상태"
|
||||
@@ -193,6 +203,7 @@ export function GiftCertificateDetail({
|
||||
value={formData.status}
|
||||
onChange={(v) => handleChange('status', v)}
|
||||
options={statusDetailOptions}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -223,13 +234,15 @@ export function GiftCertificateDetail({
|
||||
type="date"
|
||||
value={formData.usedDate}
|
||||
onChange={(v) => handleChange('usedDate', v)}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="수령인"
|
||||
value={formData.recipientName}
|
||||
onChange={(v) => handleChange('recipientName', v)}
|
||||
placeholder="수령인 이름"
|
||||
required={showUsageInfo}
|
||||
required={showUsageInfo && isEditable}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -239,12 +252,14 @@ export function GiftCertificateDetail({
|
||||
value={formData.recipientOrganization}
|
||||
onChange={(v) => handleChange('recipientOrganization', v)}
|
||||
placeholder="회사명"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="사용처/용도"
|
||||
value={formData.usageDescription}
|
||||
onChange={(v) => handleChange('usageDescription', v)}
|
||||
placeholder="내용"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,32 +270,22 @@ export function GiftCertificateDetail({
|
||||
onChange={(v) => handleChange('memo', v)}
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
rows={3}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isNew && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/ko/accounting/gift-certificates')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? '처리 중...' : isNew ? '등록' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 하단 버튼 (DetailActions sticky 패턴) */}
|
||||
<DetailActions
|
||||
mode={isNew ? 'create' : isView ? 'view' : 'edit'}
|
||||
sticky
|
||||
isSubmitting={isSubmitting}
|
||||
onBack={() => router.push('/ko/accounting/gift-certificates')}
|
||||
onCancel={() => router.push('/ko/accounting/gift-certificates')}
|
||||
onEdit={() => router.push(`/ko/accounting/gift-certificates?mode=edit&id=${id}`)}
|
||||
onDelete={!isNew ? handleDelete : undefined}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,25 @@ export async function getGiftCertificateById(
|
||||
// transform: transformDetailApiToFrontend,
|
||||
// errorMessage: '상품권 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: false, error: '상품권을 찾을 수 없습니다.' };
|
||||
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월 접대비 처리 완료',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 상품권 등록 (Mock) =====
|
||||
|
||||
@@ -21,8 +21,9 @@ import { FileText, Eye } from 'lucide-react';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||
import { purchaseConfig } from './purchaseConfig';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
||||
import { PURCHASE_TYPE_LABELS } from './types';
|
||||
import {
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { formatDate } from '@/lib/utils/date';
|
||||
|
||||
interface PurchaseDetailProps {
|
||||
purchaseId: string;
|
||||
@@ -83,8 +83,11 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const [withdrawalAccount, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
|
||||
const [createdAt, setCreatedAt] = useState('');
|
||||
|
||||
// ===== 다이얼로그 상태 =====
|
||||
// ===== 문서 열람 모달 상태 =====
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
const [approvalId, setApprovalId] = useState<string | undefined>(undefined);
|
||||
|
||||
// ===== 품목 관리 (공통 훅) =====
|
||||
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<PurchaseItem>({
|
||||
@@ -129,6 +132,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
setSourceDocument(data.sourceDocument);
|
||||
setWithdrawalAccount(data.withdrawalAccount);
|
||||
setCreatedAt(data.createdAt);
|
||||
setApprovalId(data.approvalId);
|
||||
}
|
||||
} else if (isNewMode) {
|
||||
setPurchaseNo('(자동생성)');
|
||||
@@ -139,6 +143,89 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
loadInitialData();
|
||||
}, [purchaseId, mode, isNewMode]);
|
||||
|
||||
// ===== 문서 열람 핸들러 (API 연동) =====
|
||||
const handleOpenDocument = useCallback(async () => {
|
||||
if (!approvalId) {
|
||||
toast.error('연결된 결재 문서가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsModalLoading(true);
|
||||
setDocumentModalOpen(true);
|
||||
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(approvalId));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docType = sourceDocument?.type === 'expense_report' ? 'expenseReport' : 'proposal';
|
||||
|
||||
// 기안자 정보
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: formData.basicInfo.drafter,
|
||||
position: formData.basicInfo.drafterPosition || '',
|
||||
department: formData.basicInfo.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
|
||||
// 결재자 정보
|
||||
const approvers = formData.approvalLine.map((person) => ({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status: 'approved' as const,
|
||||
}));
|
||||
|
||||
if (docType === 'expenseReport') {
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
requestDate: formData.expenseReportData?.requestDate || '',
|
||||
paymentDate: formData.expenseReportData?.paymentDate || '',
|
||||
items: formData.expenseReportData?.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
no: index + 1,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note,
|
||||
})) || [],
|
||||
cardInfo: formData.expenseReportData?.cardId || '-',
|
||||
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
||||
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
|
||||
approvers,
|
||||
drafter,
|
||||
} as ExpenseReportDocumentData);
|
||||
} else {
|
||||
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
vendor: formData.proposalData?.vendor || '-',
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
||||
title: formData.proposalData?.title || '',
|
||||
description: formData.proposalData?.description || '-',
|
||||
reason: formData.proposalData?.reason || '-',
|
||||
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
||||
attachments: uploadedFileUrls,
|
||||
approvers,
|
||||
drafter,
|
||||
} as ProposalDocumentData);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setDocumentModalOpen(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error('문서 조회 중 오류가 발생했습니다.');
|
||||
setDocumentModalOpen(false);
|
||||
} finally {
|
||||
setIsModalLoading(false);
|
||||
}
|
||||
}, [approvalId, sourceDocument]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleVendorChange = useCallback((clientId: string) => {
|
||||
const client = clients.find(c => c.id === clientId);
|
||||
@@ -224,16 +311,18 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
{sourceDocument ? (
|
||||
<>
|
||||
{/* 문서 타입 및 열람 버튼 */}
|
||||
<div className="flex items-center gap-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<Badge variant="outline" className={getPresetStyle('orange')}>
|
||||
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">연결된 문서가 있습니다</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={getPresetStyle('orange')}>
|
||||
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">연결된 문서가 있습니다</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto border-orange-300 text-orange-700 hover:bg-orange-100"
|
||||
onClick={() => setDocumentModalOpen(true)}
|
||||
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
|
||||
onClick={handleOpenDocument}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
문서 열람
|
||||
@@ -243,7 +332,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
{/* 품의서/지출결의서용 필드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 품의서/지출결의서 제목 */}
|
||||
<div className="space-y-2 col-span-2">
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} 제목</Label>
|
||||
<Input
|
||||
value={sourceDocument.title}
|
||||
@@ -297,7 +386,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* 매입 유형 */}
|
||||
<div className="space-y-2 col-span-2">
|
||||
<div className="space-y-2">
|
||||
<Label>매입 유형</Label>
|
||||
<Select
|
||||
value={purchaseType}
|
||||
@@ -446,97 +535,16 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* ===== 품의서/지출결의서 문서 열람 모달 ===== */}
|
||||
{sourceDocument && (
|
||||
{modalData && (
|
||||
<DocumentDetailModal
|
||||
open={documentModalOpen}
|
||||
onOpenChange={setDocumentModalOpen}
|
||||
documentType={sourceDocument.type === 'proposal' ? 'proposal' : 'expenseReport'}
|
||||
data={
|
||||
sourceDocument.type === 'proposal'
|
||||
? {
|
||||
documentNo: sourceDocument.documentNo,
|
||||
createdAt: formatDate(createdAt),
|
||||
vendor: vendorName,
|
||||
vendorPaymentDate: purchaseDate,
|
||||
title: sourceDocument.title,
|
||||
description: '품의 내역 상세',
|
||||
reason: '업무 수행을 위한 필수 구매',
|
||||
estimatedCost: sourceDocument.expectedCost,
|
||||
attachments: [],
|
||||
approvers: [
|
||||
{
|
||||
id: 'approver-1',
|
||||
name: '김팀장',
|
||||
position: '팀장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved',
|
||||
approvedAt: formatDate(createdAt),
|
||||
},
|
||||
{
|
||||
id: 'approver-2',
|
||||
name: '이부장',
|
||||
position: '부장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved',
|
||||
approvedAt: formatDate(createdAt),
|
||||
},
|
||||
],
|
||||
drafter: {
|
||||
id: 'drafter-1',
|
||||
name: '홍길동',
|
||||
position: '대리',
|
||||
department: '경영지원팀',
|
||||
status: 'none',
|
||||
},
|
||||
} as ProposalDocumentData
|
||||
: {
|
||||
documentNo: sourceDocument.documentNo,
|
||||
createdAt: formatDate(createdAt),
|
||||
requestDate: purchaseDate,
|
||||
paymentDate: purchaseDate,
|
||||
items: items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
no: idx + 1,
|
||||
description: item.itemName,
|
||||
amount: item.supplyPrice + item.vat,
|
||||
note: item.note || '',
|
||||
})),
|
||||
cardInfo: withdrawalAccount
|
||||
? `${withdrawalAccount.bankName} ${withdrawalAccount.accountNo}`
|
||||
: '',
|
||||
totalAmount: totals.total,
|
||||
attachments: [],
|
||||
approvers: [
|
||||
{
|
||||
id: 'approver-1',
|
||||
name: '김팀장',
|
||||
position: '팀장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved',
|
||||
approvedAt: formatDate(createdAt),
|
||||
},
|
||||
{
|
||||
id: 'approver-2',
|
||||
name: '이부장',
|
||||
position: '부장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved',
|
||||
approvedAt: formatDate(createdAt),
|
||||
},
|
||||
],
|
||||
drafter: {
|
||||
id: 'drafter-1',
|
||||
name: '홍길동',
|
||||
position: '대리',
|
||||
department: '경영지원팀',
|
||||
status: 'none',
|
||||
},
|
||||
} as ExpenseReportDocumentData
|
||||
}
|
||||
onEdit={() => toast.info('문서 수정 기능 준비 중입니다.')}
|
||||
onCopy={() => toast.info('문서 복제 기능 준비 중입니다.')}
|
||||
onApprove={() => toast.info('승인 기능 준비 중입니다.')}
|
||||
onReject={() => toast.info('반려 기능 준비 중입니다.')}
|
||||
onOpenChange={(open) => {
|
||||
setDocumentModalOpen(open);
|
||||
if (!open) setModalData(null);
|
||||
}}
|
||||
mode="reference"
|
||||
documentType={sourceDocument?.type === 'expense_report' ? 'expenseReport' : 'proposal'}
|
||||
data={modalData}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { FileText, Plus, X, ExternalLink } from 'lucide-react';
|
||||
import { FileText, Plus, Trash2, ExternalLink } from 'lucide-react';
|
||||
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
||||
import { PURCHASE_TYPE_LABELS } from './types';
|
||||
import { getBankAccounts, getVendors } from './actions';
|
||||
@@ -343,7 +343,7 @@ export function PurchaseDetailModal({
|
||||
className="h-8 w-8 text-red-600"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function getPurchases(params?: {
|
||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||
clientId?: string; status?: string; search?: string;
|
||||
}) {
|
||||
return executePaginatedAction<PurchaseApiData, PurchaseRecord>({
|
||||
const result = await executePaginatedAction<PurchaseApiData, PurchaseRecord>({
|
||||
url: buildApiUrl('/api/v1/purchases', {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
@@ -111,6 +111,7 @@ export async function getPurchases(params?: {
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '매입 목록 조회에 실패했습니다.',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 매입 상세 조회 =====
|
||||
|
||||
@@ -8,7 +8,9 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -348,16 +350,16 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
{/* 헤더 액션 (연도 선택, 버튼) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-1 gap-3 sm:flex sm:items-center sm:gap-4">
|
||||
{/* 연도 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">연도</span>
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">연도</span>
|
||||
<Select
|
||||
value={String(selectedYear)}
|
||||
onValueChange={(value) => setSelectedYear(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="연도 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -372,12 +374,12 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
|
||||
{/* 정렬 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">정렬</span>
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">정렬</span>
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -390,7 +392,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -444,19 +446,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 */}
|
||||
<Card>
|
||||
{/* 테이블 - xl 이상에서만 표시 */}
|
||||
<Card className="hidden xl:block">
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table className="border-separate border-spacing-0" style={{ minWidth: `${200 + 70 + (monthCount * 100) + 120}px` }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 거래처/연체 - 왼쪽 고정 */}
|
||||
<TableHead className="w-[200px] min-w-[200px] max-w-[200px] sticky left-0 z-20 bg-white border-r border-gray-200">
|
||||
<TableHead className="w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] sticky left-0 z-20 bg-white border-r border-gray-200">
|
||||
거래처 / 연체
|
||||
</TableHead>
|
||||
{/* 구분 - 왼쪽 고정 (거래처 옆) */}
|
||||
<TableHead className="w-[70px] min-w-[70px] text-center sticky left-[200px] z-20 bg-white border-r border-gray-200">
|
||||
{/* 구분 - sm 이상에서만 왼쪽 고정 */}
|
||||
<TableHead className="w-[70px] min-w-[70px] text-center sm:sticky sm:left-[200px] z-20 bg-white border-r border-gray-200">
|
||||
구분
|
||||
</TableHead>
|
||||
{/* 동적 월 레이블 - 스크롤 영역 */}
|
||||
@@ -465,8 +467,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
{month}
|
||||
</TableHead>
|
||||
))}
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableHead className="w-[100px] min-w-[100px] text-right sticky right-0 z-20 bg-white border-l border-gray-200">
|
||||
{/* 합계 - sm 이상에서만 오른쪽 고정 */}
|
||||
<TableHead className="w-[100px] min-w-[100px] text-right sm:sticky sm:right-0 z-20 bg-white sm:border-l border-gray-200">
|
||||
합계
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -514,7 +516,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
>
|
||||
{/* 거래처명 - 왼쪽 고정 (매 행마다 개별 셀, 첫 행만 내용 표시) */}
|
||||
<TableCell
|
||||
className={`font-medium border-r border-gray-200 sticky left-0 z-10 w-[200px] min-w-[200px] max-w-[200px] overflow-hidden whitespace-normal ${rowBgClass} ${catIndex === 0 ? 'align-top pt-3' : 'p-0'}`}
|
||||
className={`font-medium border-r border-gray-200 sticky left-0 z-10 w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] overflow-hidden whitespace-normal ${rowBgClass} ${catIndex === 0 ? 'align-top pt-3' : 'p-0'}`}
|
||||
>
|
||||
{catIndex === 0 && (
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
@@ -533,8 +535,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 구분 - 왼쪽 고정 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[200px] z-10 ${rowBgClass}`}>
|
||||
{/* 구분 - sm 이상에서만 왼쪽 고정 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sm:sticky sm:left-[200px] z-10 ${rowBgClass}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</TableCell>
|
||||
|
||||
@@ -548,8 +550,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
|
||||
{/* 합계 - sm 이상에서만 오른쪽 고정 */}
|
||||
<TableCell className={`text-right font-medium text-sm sm:sticky sm:right-0 z-10 sm:border-l border-gray-200 ${rowBgClass}`}>
|
||||
{formatAmount(categoryData.amounts.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -561,10 +563,10 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
rows.push(
|
||||
<TableRow key={`${vendor.id}-memo`}>
|
||||
{/* 거래처명 셀 (빈 셀 - 시각적 병합 유지) */}
|
||||
<TableCell className={`border-r border-gray-200 sticky left-0 z-10 w-[200px] min-w-[200px] max-w-[200px] overflow-hidden p-0 ${rowBgClass}`} />
|
||||
<TableCell className={`border-r border-gray-200 sticky left-0 z-10 w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] overflow-hidden p-0 ${rowBgClass}`} />
|
||||
|
||||
{/* 구분: 메모 + 접기/펼치기 버튼 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[200px] z-10 ${rowBgClass}`}>
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sm:sticky sm:left-[200px] z-10 ${rowBgClass}`}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>메모</span>
|
||||
<button
|
||||
@@ -608,7 +610,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
<TableCell className="border-r border-gray-200 sticky left-0 z-10 bg-gray-100">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-center border-r border-gray-200 sticky left-[200px] z-10 bg-gray-100">
|
||||
<TableCell className="text-center border-r border-gray-200 sm:sticky sm:left-[200px] z-10 bg-gray-100">
|
||||
미수금
|
||||
</TableCell>
|
||||
{/* 월별 합계 - 스크롤 영역 */}
|
||||
@@ -617,8 +619,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
{formatAmount(amount)}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* 총합계 - 오른쪽 고정 */}
|
||||
<TableCell className="text-right font-bold sticky right-0 z-10 bg-gray-100 border-l border-gray-200">
|
||||
{/* 총합계 - sm 이상에서만 오른쪽 고정 */}
|
||||
<TableCell className="text-right font-bold sm:sticky sm:right-0 z-10 bg-gray-100 sm:border-l border-gray-200">
|
||||
{formatAmount(grandTotals.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -627,6 +629,104 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모바일 카드 뷰 - xl 미만에서만 표시 */}
|
||||
<div className="xl:hidden space-y-3">
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="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">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : sortedData.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-gray-500">
|
||||
검색 결과가 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{sortedData.map((vendor) => {
|
||||
const salesCat = vendor.categories.find(c => c.category === 'sales');
|
||||
const depositCat = vendor.categories.find(c => c.category === 'deposit');
|
||||
const billCat = vendor.categories.find(c => c.category === 'bill');
|
||||
const receivableCat = vendor.categories.find(c => c.category === 'receivable');
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={vendor.id}
|
||||
title={vendor.vendorName}
|
||||
className={
|
||||
isHighlighted
|
||||
? 'border-yellow-400 bg-yellow-50'
|
||||
: vendor.isOverdue
|
||||
? 'border-red-300 bg-red-50/50'
|
||||
: undefined
|
||||
}
|
||||
headerBadges={
|
||||
vendor.isOverdue ? (
|
||||
<Badge variant="destructive" className="text-xs">연체</Badge>
|
||||
) : null
|
||||
}
|
||||
subtitle={`미수금 합계: ${formatNumber(receivableCat?.amounts.total || 0)}원`}
|
||||
collapsible
|
||||
defaultExpanded={false}
|
||||
infoGrid={
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
{ label: '매출', value: salesCat?.amounts.total || 0 },
|
||||
{ label: '입금', value: depositCat?.amounts.total || 0 },
|
||||
{ label: '어음', value: billCat?.amounts.total || 0 },
|
||||
{ label: '미수금', value: receivableCat?.amounts.total || 0, bold: true },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className={item.bold ? 'font-bold' : 'font-medium'}>
|
||||
{formatNumber(item.value)}원
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
bottomContent={
|
||||
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={vendor.isOverdue}
|
||||
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
|
||||
className="data-[state=checked]:bg-red-500"
|
||||
/>
|
||||
<span className="text-sm">연체 설정</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={vendor.memo}
|
||||
onChange={(e) => handleMemoChange(vendor.id, e.target.value)}
|
||||
placeholder="거래처 메모를 입력하세요..."
|
||||
className="text-sm resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 합계 카드 */}
|
||||
<Card className="bg-gray-50 border-gray-300">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-sm">미수금 총합계</span>
|
||||
<span className="font-bold text-lg">{formatNumber(grandTotals.total)}원</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -350,22 +350,34 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<CardTitle className="text-lg">거래명세서</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="transactionStatement">거래명세서 발행</Label>
|
||||
<Switch
|
||||
id="transactionStatement"
|
||||
checked={transactionStatementIssued}
|
||||
onCheckedChange={setTransactionStatementIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="transactionStatement">거래명세서 발행</Label>
|
||||
<Switch
|
||||
id="transactionStatement"
|
||||
checked={transactionStatementIssued}
|
||||
onCheckedChange={setTransactionStatementIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{transactionStatementIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={() => {
|
||||
// TODO: 거래명세서 조회 기능 연결
|
||||
toast.info('거래명세서 조회 기능 준비 중입니다.');
|
||||
}}
|
||||
>
|
||||
@@ -383,17 +395,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
거래명세서 발행하기
|
||||
</Button>
|
||||
)}
|
||||
{transactionStatementIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -198,9 +198,9 @@ export function TaxInvoiceIssuancePage({
|
||||
|
||||
{/* 전자세금계산서 발행 섹션 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-4">
|
||||
<CardTitle className="text-base">전자세금계산서 발행</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -89,17 +89,17 @@ export function CardHistoryModal({
|
||||
{/* 검색 영역 */}
|
||||
<div className="space-y-2">
|
||||
{/* Row1: 날짜 범위 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className="flex-1"
|
||||
className="w-full sm:flex-1"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">~</span>
|
||||
<span className="text-sm text-muted-foreground shrink-0 text-center">~</span>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
className="flex-1"
|
||||
className="w-full sm:flex-1"
|
||||
/>
|
||||
</div>
|
||||
{/* Row2: 검색어 + 조회 */}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function ManualEntryModal({
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 구분 + 작성일자 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">구분</Label>
|
||||
<Select
|
||||
@@ -181,7 +181,7 @@ export function ManualEntryModal({
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="space-y-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<Label className="text-sm font-semibold">공급자 정보</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -192,7 +192,7 @@ export function ManualEntryModal({
|
||||
카드 내역 불러오기
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
label="공급자명"
|
||||
value={formData.vendorName}
|
||||
@@ -209,7 +209,7 @@ export function ManualEntryModal({
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<FormField
|
||||
label="공급가액"
|
||||
type="number"
|
||||
@@ -234,7 +234,7 @@ export function ManualEntryModal({
|
||||
</div>
|
||||
|
||||
{/* 품목 + 과세유형 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
label="품목"
|
||||
value={formData.itemName}
|
||||
|
||||
@@ -355,7 +355,7 @@ export function TaxInvoiceManagement() {
|
||||
/>
|
||||
|
||||
{/* 매출/매입 탭 + 액션 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as InvoiceTab)}>
|
||||
<TabsList>
|
||||
{TAB_OPTIONS.map((t) => (
|
||||
@@ -447,8 +447,6 @@ export function TaxInvoiceManagement() {
|
||||
subtitle={item.documentNumber || item.writeDate}
|
||||
badge={INVOICE_STATUS_MAP[item.status].label}
|
||||
badgeVariant="outline"
|
||||
isSelected={false}
|
||||
onToggle={() => {}}
|
||||
onClick={() => setJournalTarget(item)}
|
||||
details={[
|
||||
{ label: '작성일자', value: item.writeDate },
|
||||
@@ -484,20 +482,21 @@ export function TaxInvoiceManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm font-semibold mb-3">기간 요약</div>
|
||||
<div className="flex items-center justify-center gap-4 text-center">
|
||||
<div>
|
||||
{/* 모바일: 세로 스택, sm 이상: 가로 배치 */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-2 sm:gap-4 text-center">
|
||||
<div className="w-full sm:w-auto">
|
||||
<div className="text-xs text-muted-foreground mb-1">매출 합계 (공급가액 + 세액)</div>
|
||||
<div className="text-xl font-bold">{formatNumber(summary.salesTotalAmount)}원</div>
|
||||
<div className="text-lg sm:text-xl font-bold">{formatNumber(summary.salesTotalAmount)}원</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-muted-foreground">⊖</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-bold text-muted-foreground">⊖</div>
|
||||
<div className="w-full sm:w-auto">
|
||||
<div className="text-xs text-muted-foreground mb-1">매입 합계 (공급가액 + 세액)</div>
|
||||
<div className="text-xl font-bold">{formatNumber(summary.purchaseTotalAmount)}원</div>
|
||||
<div className="text-lg sm:text-xl font-bold">{formatNumber(summary.purchaseTotalAmount)}원</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-muted-foreground">=</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-bold text-muted-foreground">=</div>
|
||||
<div className="w-full sm:w-auto">
|
||||
<div className="text-xs text-muted-foreground mb-1">예상 부가세</div>
|
||||
<div className={`text-xl font-bold ${periodDifference >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
|
||||
<div className={`text-lg sm:text-xl font-bold ${periodDifference >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
|
||||
{formatNumber(periodDifference)}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,44 +196,44 @@ export function VendorLedgerDetail({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3">
|
||||
{/* 좌측 열 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">회사명</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">회사명</span>
|
||||
<span className="font-medium">{vendorDetail.vendorName || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">사업자등록번호</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">사업자등록번호</span>
|
||||
<span>{vendorDetail.businessNumber || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">전화번호</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">전화번호</span>
|
||||
<span>{vendorDetail.phone || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">팩스</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">팩스</span>
|
||||
<span>{vendorDetail.fax || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">주소</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">주소</span>
|
||||
<span>{vendorDetail.address || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 열 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">기간</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">기간</span>
|
||||
<span>{vendorDetail.periodStart || startDate} ~ {vendorDetail.periodEnd || endDate}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">대표자</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">대표자</span>
|
||||
<span>{vendorDetail.representativeName || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">모바일</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">모바일</span>
|
||||
<span>{vendorDetail.mobile || '-'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-500 w-28 shrink-0">이메일</span>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<span className="text-gray-500 sm:w-28 sm:shrink-0 text-sm">이메일</span>
|
||||
<span>{vendorDetail.email || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +245,7 @@ export function VendorLedgerDetail({
|
||||
{summary && (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">이월잔액</div>
|
||||
<div className="font-medium">{formatNumber(summary.carryoverBalance)}원</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, Upload } from 'lucide-react';
|
||||
import { Plus, Trash2, Upload, FileText, X, CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getClientById, createClient, updateClient, deleteClient } from './actions';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
@@ -151,6 +151,16 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 새 메모 입력
|
||||
const [newMemo, setNewMemo] = useState('');
|
||||
|
||||
// 사업자등록증 파일
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE_MB = 5;
|
||||
const ACCEPT_TYPES = '.pdf,.jpg,.jpeg,.png,.gif,.bmp,.tiff';
|
||||
const DANGEROUS_EXTENSIONS = ['.exe', '.bat', '.cmd', '.sh', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.jar', '.app', '.scr', '.vbs', '.ps1', '.htm', '.html', '.svg', '.swf'];
|
||||
const [businessFiles, setBusinessFiles] = useState<File[]>([]);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 신용분석 모달
|
||||
const [isCreditModalOpen, setIsCreditModalOpen] = useState(false);
|
||||
|
||||
@@ -184,6 +194,48 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 파일 검증 및 추가
|
||||
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
|
||||
setFileError(null);
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
if (businessFiles.length + fileArray.length > MAX_FILES) {
|
||||
setFileError(`최대 ${MAX_FILES}건까지 업로드 가능합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of fileArray) {
|
||||
const ext = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
setFileError('보안상 허용되지 않는 파일 형식입니다.');
|
||||
return;
|
||||
}
|
||||
const allowedExts = ACCEPT_TYPES.split(',').map(e => e.trim().toLowerCase());
|
||||
if (!allowedExts.some(allowed => ext === allowed)) {
|
||||
setFileError(`허용되지 않은 파일 형식입니다. (${ACCEPT_TYPES})`);
|
||||
return;
|
||||
}
|
||||
if (file.size / (1024 * 1024) > MAX_FILE_SIZE_MB) {
|
||||
setFileError(`파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusinessFiles(prev => [...prev, ...fileArray]);
|
||||
}, [businessFiles.length]);
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
validateAndAddFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}, [validateAndAddFiles]);
|
||||
|
||||
const handleRemoveFile = useCallback((index: number) => {
|
||||
setBusinessFiles(prev => prev.filter((_, i) => i !== index));
|
||||
setFileError(null);
|
||||
}, []);
|
||||
|
||||
// 메모 추가 핸들러
|
||||
const handleAddMemo = useCallback(() => {
|
||||
if (!newMemo.trim()) return;
|
||||
@@ -354,16 +406,81 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
{/* 사업자등록증 파일 업로드 영역 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">사업자등록증</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">클릭하여 파일을 올리거나, 드래그로 파일을 올립니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">파일은 최대 5건, 1건당 5MB 이하</p>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" className="mt-2">
|
||||
파일 업로드
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT_TYPES}
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{businessFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{businessFiles.map((file, idx) => (
|
||||
<div
|
||||
key={`${file.name}-${idx}`}
|
||||
className="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleRemoveFile(idx)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드래그앤드롭 / 클릭 업로드 영역 */}
|
||||
{!isViewMode && businessFiles.length < MAX_FILES && (
|
||||
<div
|
||||
onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setIsDragging(false);
|
||||
if (e.dataTransfer.files) validateAndAddFiles(e.dataTransfer.files);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">클릭하여 파일을 올리거나, 드래그로 파일을 올립니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
PDF, 이미지 파일 / 최대 {MAX_FILES}건, 1건당 {MAX_FILE_SIZE_MB}MB 이하
|
||||
{businessFiles.length > 0 && ` (${businessFiles.length}/${MAX_FILES})`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* view 모드에서 파일 없을 때 */}
|
||||
{isViewMode && businessFiles.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-3">등록된 파일이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{fileError && (
|
||||
<p className="text-sm text-red-500">{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 사업자등록번호 - BusinessNumberInput 사용 */}
|
||||
@@ -376,7 +493,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onChange={(value) => handleChange('businessNumber', value)}
|
||||
placeholder="000-00-00000"
|
||||
disabled={isViewMode}
|
||||
showValidation
|
||||
showValidation={!isViewMode}
|
||||
error={!!validationErrors.businessNumber}
|
||||
/>
|
||||
</div>
|
||||
@@ -652,7 +769,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDeleteMemo(memo.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Plus, X, Upload } from 'lucide-react';
|
||||
import { Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
@@ -588,7 +588,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDeleteMemo(memo.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user