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:
유병철
2026-02-26 21:27:40 +09:00
parent 2777ecf664
commit b1686aaf66
107 changed files with 1703 additions and 970 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
{/* 테이블 */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}` },

View File

@@ -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>
);
}

View File

@@ -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) =====

View File

@@ -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}
/>
)}

View File

@@ -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>

View File

@@ -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;
}
// ===== 매입 상세 조회 =====

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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: 검색어 + 조회 */}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>