feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선 - Sidebar/AuthenticatedLayout 소폭 수정 - ShipmentCreate, VehicleDispatch 출하 관련 개선 - WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선 - InspectionCreate 자재 입고검사 개선 - DailyReport, VendorDetail 회계 수정 - CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선 - useCEODashboard, expense transformer 정비 - DocumentViewer, PDF generate route 소폭 수정 - bill-prototype 개발 페이지 추가 - mockData 불필요 데이터 제거
This commit is contained in:
955
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
955
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
@@ -0,0 +1,955 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Plus, Trash2, AlertTriangle, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
// ===== 증권 종류 =====
|
||||
const INSTRUMENT_TYPE_OPTIONS = [
|
||||
{ value: 'promissory', label: '약속어음' },
|
||||
{ value: 'exchange', label: '환어음' },
|
||||
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
|
||||
{ value: 'currentCheck', label: '당좌수표' },
|
||||
];
|
||||
|
||||
// ===== 거래 방향 =====
|
||||
const BILL_DIRECTION_OPTIONS = [
|
||||
{ value: 'received', label: '수취 (받을어음)' },
|
||||
{ value: 'issued', label: '발행 (지급어음)' },
|
||||
];
|
||||
|
||||
// ===== 전자/지류 =====
|
||||
const MEDIUM_OPTIONS = [
|
||||
{ value: 'electronic', label: '전자' },
|
||||
{ value: 'paper', label: '지류 (종이)' },
|
||||
];
|
||||
|
||||
// ===== 배서 가능 여부 =====
|
||||
const ENDORSEMENT_OPTIONS = [
|
||||
{ value: 'endorsable', label: '배서 가능' },
|
||||
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
|
||||
];
|
||||
|
||||
// ===== 상태 (수취) =====
|
||||
const RECEIVED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'discounted', label: '할인' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
];
|
||||
|
||||
// ===== 상태 (발행) =====
|
||||
const ISSUED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityPayment', label: '만기결제' },
|
||||
{ value: 'collectionRequest', label: '추심의뢰' },
|
||||
{ value: 'collectionComplete', label: '추심완료' },
|
||||
{ value: 'suing', label: '추소중' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
];
|
||||
|
||||
// ===== 차수 처리구분 =====
|
||||
const INSTALLMENT_TYPE_OPTIONS = [
|
||||
{ value: 'endorsement', label: '배서양도' },
|
||||
{ value: 'collection', label: '추심' },
|
||||
{ value: 'discount', label: '할인' },
|
||||
{ value: 'payment', label: '결제' },
|
||||
{ value: 'split', label: '분할' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== 부도사유 =====
|
||||
const DISHONOR_REASON_OPTIONS = [
|
||||
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
|
||||
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
|
||||
{ value: 'formal_defect', label: '형식불비' },
|
||||
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
|
||||
{ value: 'expired', label: '제시기간 경과' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== 차수 레코드 (확장) =====
|
||||
interface InstallmentRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
counterparty: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
interface BillFormData {
|
||||
// 기본 정보
|
||||
billNumber: string;
|
||||
instrumentType: string;
|
||||
direction: string;
|
||||
medium: string;
|
||||
endorsement: string;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: string;
|
||||
note: string;
|
||||
issuerBank: string;
|
||||
paymentPlace: string;
|
||||
bankAccountInfo: string;
|
||||
// 전자어음 추가 (조건: medium = electronic)
|
||||
electronicBillNo: string;
|
||||
registrationOrg: string;
|
||||
// 환어음 추가 (조건: instrumentType = exchange)
|
||||
drawee: string;
|
||||
acceptanceStatus: string;
|
||||
acceptanceDate: string;
|
||||
// 할인 정보 (조건: status = discounted)
|
||||
discountDate: string;
|
||||
discountBank: string;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
netReceivedAmount: number;
|
||||
// 배서양도 정보 (조건: status = endorsed)
|
||||
endorsementDate: string;
|
||||
endorsee: string;
|
||||
endorsementReason: string;
|
||||
// 추심 정보 (조건: status = collected/collectionRequest)
|
||||
collectionBank: string;
|
||||
collectionRequestDate: string;
|
||||
collectionFee: number;
|
||||
// 분할 정보
|
||||
isSplit: boolean;
|
||||
splitCount: number;
|
||||
splitAmount: number;
|
||||
// 부도 정보 (조건: status = dishonored)
|
||||
dishonoredDate: string;
|
||||
dishonoredReason: string;
|
||||
// 차수 관리
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM: BillFormData = {
|
||||
billNumber: '',
|
||||
instrumentType: 'promissory',
|
||||
direction: 'received',
|
||||
medium: 'paper',
|
||||
endorsement: 'endorsable',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
issuerBank: '',
|
||||
paymentPlace: '',
|
||||
bankAccountInfo: '',
|
||||
electronicBillNo: '',
|
||||
registrationOrg: '',
|
||||
drawee: '',
|
||||
acceptanceStatus: '',
|
||||
acceptanceDate: '',
|
||||
discountDate: '',
|
||||
discountBank: '',
|
||||
discountRate: 0,
|
||||
discountAmount: 0,
|
||||
netReceivedAmount: 0,
|
||||
endorsementDate: '',
|
||||
endorsee: '',
|
||||
endorsementReason: '',
|
||||
collectionBank: '',
|
||||
collectionRequestDate: '',
|
||||
collectionFee: 0,
|
||||
isSplit: false,
|
||||
splitCount: 0,
|
||||
splitAmount: 0,
|
||||
dishonoredDate: '',
|
||||
dishonoredReason: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
// ===== NEW 뱃지 =====
|
||||
function NewBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="ml-2 text-[10px] px-1.5 py-0 bg-orange-50 text-orange-600 border-orange-200">
|
||||
NEW
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 조건부 뱃지 =====
|
||||
function CondBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="ml-2 text-[10px] px-1.5 py-0 bg-purple-50 text-purple-600 border-purple-200">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillPrototypePage() {
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM);
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 상태 옵션 (방향에 따라)
|
||||
const statusOptions = formData.direction === 'received'
|
||||
? RECEIVED_STATUS_OPTIONS
|
||||
: ISSUED_STATUS_OPTIONS;
|
||||
|
||||
// 조건부 표시 플래그
|
||||
const showElectronic = formData.medium === 'electronic';
|
||||
const showExchangeBill = formData.instrumentType === 'exchange';
|
||||
const showDiscount = formData.status === 'discounted';
|
||||
const showEndorsement = formData.status === 'endorsed';
|
||||
const showCollection = ['collected', 'collectionRequest', 'collectionComplete'].includes(formData.status);
|
||||
const showDishonored = formData.status === 'dishonored';
|
||||
|
||||
// 할인 실수령액 자동계산
|
||||
const calcNetReceived = useMemo(() => {
|
||||
if (formData.amount > 0 && formData.discountAmount > 0) {
|
||||
return formData.amount - formData.discountAmount;
|
||||
}
|
||||
return 0;
|
||||
}, [formData.amount, formData.discountAmount]);
|
||||
|
||||
// 분할 합계
|
||||
const splitTotal = formData.splitCount * formData.splitAmount;
|
||||
|
||||
// 차수 관리
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
type: 'payment',
|
||||
amount: 0,
|
||||
counterparty: '',
|
||||
note: '',
|
||||
}],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">어음 등록 (개선안 프로토타입 v2)</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">실무자 확인용 - 조건부 필드 포함</p>
|
||||
</div>
|
||||
|
||||
{/* 안내 배너 */}
|
||||
<Card className="mb-6 border-blue-200 bg-blue-50">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<Info className="h-4 w-4 flex-shrink-0" />
|
||||
<span>이 페이지는 <strong>프로토타입</strong>입니다. 실제 데이터 저장되지 않습니다.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-6 text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-orange-50 text-orange-600 border-orange-200">NEW</Badge>
|
||||
신규 필드
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-purple-50 text-purple-600 border-purple-200">조건부</Badge>
|
||||
특정 조건에서만 표시
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>어음번호 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="자동생성 또는 직접입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={(v) => updateField('instrumentType', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={formData.direction}
|
||||
onValueChange={(v) => { updateField('direction', v); updateField('status', 'stored'); }}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배서 여부 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부<NewBadge /></Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.vendorId} onValueChange={(v) => updateField('vendorId', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">삼성전자</SelectItem>
|
||||
<SelectItem value="2">LG전자</SelectItem>
|
||||
<SelectItem value="3">현대건설</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(value) => updateField('amount', value ?? 0)} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(date) => updateField('issueDate', date)} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(date) => updateField('maturityDate', date)} />
|
||||
</div>
|
||||
|
||||
{/* 발행은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행은행<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.issuerBank}
|
||||
onChange={(e) => updateField('issuerBank', e.target.value)}
|
||||
placeholder="예: 국민은행"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급지<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.paymentPlace}
|
||||
onChange={(e) => updateField('paymentPlace', e.target.value)}
|
||||
placeholder="예: 국민은행 강남지점"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태 <span className="text-red-500">*</span></Label>
|
||||
<Select key={`status-${formData.direction}`} value={formData.status} onValueChange={(v) => updateField('status', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 입금/출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌<NewBadge /></Label>
|
||||
<Select value={formData.bankAccountInfo} onValueChange={(v) => updateField('bankAccountInfo', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="계좌 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">국민은행 123-456-789 (보통예금)</SelectItem>
|
||||
<SelectItem value="2">신한은행 987-654-321 (당좌예금)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 전자어음 추가 정보 (조건: 전자/지류 = 전자) ===== */}
|
||||
{showElectronic && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
전자어음 정보
|
||||
<CondBadge label="전자 선택 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.electronicBillNo}
|
||||
onChange={(e) => updateField('electronicBillNo', e.target.value)}
|
||||
placeholder="전자어음시스템 발급번호"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관<NewBadge /></Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 환어음 추가 정보 (조건: 증권종류 = 환어음) ===== */}
|
||||
{showExchangeBill && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
환어음 정보
|
||||
<CondBadge label="환어음 선택 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>지급인 (Drawee) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.drawee}
|
||||
onChange={(e) => updateField('drawee', e.target.value)}
|
||||
placeholder="지급 의무자"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부<NewBadge /></Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accepted">인수 완료</SelectItem>
|
||||
<SelectItem value="pending">인수 대기</SelectItem>
|
||||
<SelectItem value="refused">인수 거절</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수일자<NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceDate}
|
||||
onChange={(date) => updateField('acceptanceDate', date)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 할인 정보 (조건: 상태 = 할인) ===== */}
|
||||
{showDiscount && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
할인 정보
|
||||
<CondBadge label="상태=할인 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>할인일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.discountDate}
|
||||
onChange={(date) => updateField('discountDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.discountBank}
|
||||
onChange={(e) => updateField('discountBank', e.target.value)}
|
||||
placeholder="예: 국민은행 강남지점"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)<NewBadge /></Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
max={100}
|
||||
value={formData.discountRate || ''}
|
||||
onChange={(e) => {
|
||||
const rate = parseFloat(e.target.value) || 0;
|
||||
updateField('discountRate', rate);
|
||||
// 할인율 변경 시 할인금액 자동계산
|
||||
if (formData.amount > 0 && rate > 0) {
|
||||
updateField('discountAmount', Math.round(formData.amount * rate / 100));
|
||||
}
|
||||
}}
|
||||
placeholder="예: 3.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액<NewBadge /></Label>
|
||||
<CurrencyInput
|
||||
value={formData.discountAmount}
|
||||
onChange={(value) => updateField('discountAmount', value ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>실수령액 (자동계산)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
|
||||
{calcNetReceived > 0
|
||||
? <span className="text-green-700">₩ {calcNetReceived.toLocaleString()}</span>
|
||||
: <span className="text-gray-400">어음금액 - 할인금액</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 배서양도 정보 (조건: 상태 = 배서양도) ===== */}
|
||||
{showEndorsement && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
배서양도 정보
|
||||
<CondBadge label="상태=배서양도 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>배서일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.endorsementDate}
|
||||
onChange={(date) => updateField('endorsementDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.endorsee}
|
||||
onChange={(e) => updateField('endorsee', e.target.value)}
|
||||
placeholder="어음을 넘겨받는 자"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유<NewBadge /></Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment">대금결제</SelectItem>
|
||||
<SelectItem value="guarantee">담보제공</SelectItem>
|
||||
<SelectItem value="collection">추심위임</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 추심 정보 (조건: 상태 = 추심/추심의뢰/추심완료) ===== */}
|
||||
{showCollection && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
추심 정보
|
||||
<CondBadge label="상태=추심 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.collectionBank}
|
||||
onChange={(e) => updateField('collectionBank', e.target.value)}
|
||||
placeholder="추심 의뢰 은행"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.collectionRequestDate}
|
||||
onChange={(date) => updateField('collectionRequestDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료<NewBadge /></Label>
|
||||
<CurrencyInput
|
||||
value={formData.collectionFee}
|
||||
onChange={(value) => updateField('collectionFee', value ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 분할 정보 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
분할 정보<NewBadge />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.isSplit}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField('isSplit', checked);
|
||||
if (!checked) { updateField('splitCount', 0); updateField('splitAmount', 0); }
|
||||
}}
|
||||
/>
|
||||
<Label>분할 어음</Label>
|
||||
</div>
|
||||
{formData.isSplit && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>분할 장수</Label>
|
||||
<Input
|
||||
type="number" min={1}
|
||||
value={formData.splitCount || ''}
|
||||
onChange={(e) => updateField('splitCount', parseInt(e.target.value) || 0)}
|
||||
placeholder="장수 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>장당 금액</Label>
|
||||
<CurrencyInput value={formData.splitAmount} onChange={(value) => updateField('splitAmount', value ?? 0)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>분할 합계</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-medium">
|
||||
{splitTotal > 0 ? `₩ ${splitTotal.toLocaleString()}` : '-'}
|
||||
{splitTotal > 0 && formData.amount > 0 && splitTotal !== formData.amount && (
|
||||
<span className="ml-2 text-red-500 text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
어음 금액과 불일치
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 부도 정보 (조건: 상태 = 부도) ===== */}
|
||||
{showDishonored && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<CondBadge label="상태=부도 시" />
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.dishonoredDate}
|
||||
onChange={(date) => updateField('dishonoredDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 차수 관리 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
차수 관리<Badge variant="secondary" className="text-xs">확장</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[130px]">처리구분<NewBadge /></TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처<NewBadge /></TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
<TableHead className="w-[60px]">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)} size="sm" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => handleUpdateInstallment(inst.id, 'type', v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTALLMENT_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.counterparty}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'counterparty', e.target.value)}
|
||||
placeholder="양수인/추심처"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 조건부 필드 가이드 (실무자 확인용) ===== */}
|
||||
<Card className="border-gray-300 bg-gray-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">조건부 필드 가이드 (실무자 확인용)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-gray-600 mb-4">아래 조건을 변경하면 해당 섹션이 자동으로 나타납니다. 직접 선택해서 확인해보세요.</p>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">조건</TableHead>
|
||||
<TableHead className="w-[200px]">나타나는 섹션</TableHead>
|
||||
<TableHead>포함 필드</TableHead>
|
||||
<TableHead className="w-[80px]">현재</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className={showElectronic ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">전자/지류 = 전자</TableCell>
|
||||
<TableCell>전자어음 정보</TableCell>
|
||||
<TableCell>전자어음관리번호, 등록기관</TableCell>
|
||||
<TableCell>{showElectronic ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showExchangeBill ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">증권종류 = 환어음</TableCell>
|
||||
<TableCell>환어음 정보</TableCell>
|
||||
<TableCell>지급인(drawee), 인수여부, 인수일자</TableCell>
|
||||
<TableCell>{showExchangeBill ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showDiscount ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 할인</TableCell>
|
||||
<TableCell>할인 정보</TableCell>
|
||||
<TableCell>할인일자, 할인처, 할인율, 할인금액, 실수령액(자동)</TableCell>
|
||||
<TableCell>{showDiscount ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showEndorsement ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 배서양도</TableCell>
|
||||
<TableCell>배서양도 정보</TableCell>
|
||||
<TableCell>배서일자, 피배서인(양수인), 배서사유</TableCell>
|
||||
<TableCell>{showEndorsement ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showCollection ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 추심/추심의뢰</TableCell>
|
||||
<TableCell>추심 정보</TableCell>
|
||||
<TableCell>추심은행, 추심의뢰일, 추심수수료</TableCell>
|
||||
<TableCell>{showCollection ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showDishonored ? 'bg-red-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 부도</TableCell>
|
||||
<TableCell>부도 정보</TableCell>
|
||||
<TableCell>부도일자, 부도사유 (1호/2호/형식불비 등)</TableCell>
|
||||
<TableCell>{showDishonored ? <Badge variant="destructive" className="text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={formData.isSplit ? 'bg-orange-50' : ''}>
|
||||
<TableCell className="font-medium">분할 토글 ON</TableCell>
|
||||
<TableCell>분할 상세</TableCell>
|
||||
<TableCell>장수, 장당금액, 합계(자동/불일치 경고)</TableCell>
|
||||
<TableCell>{formData.isSplit ? <Badge className="bg-orange-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
<Button variant="outline">취소</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => {
|
||||
alert('프로토타입입니다. 실제 저장되지 않습니다.');
|
||||
}}>
|
||||
등록 (데모)
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
// HTML 설정
|
||||
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const resourceType = req.resourceType();
|
||||
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
|
||||
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
|
||||
req.abort();
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
|
||||
await page.setContent(fullHtml, {
|
||||
waitUntil: 'networkidle0',
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { printElement } from '@/lib/print-utils';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
@@ -204,9 +205,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const printAreaRef = useRef<HTMLDivElement>(null);
|
||||
const handlePrint = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
if (printAreaRef.current) {
|
||||
printElement(printAreaRef.current, {
|
||||
title: `일일일보_${startDate}`,
|
||||
styles: `
|
||||
.print-container { font-size: 11px; }
|
||||
table { width: 100%; margin-bottom: 12px; }
|
||||
h3 { margin-bottom: 8px; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
}, [startDate]);
|
||||
|
||||
// ===== USD 금액 포맷 =====
|
||||
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
|
||||
@@ -299,6 +310,8 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 인쇄 영역 */}
|
||||
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
|
||||
{/* 일자별 입출금 합계 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
@@ -660,6 +673,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
|
||||
import { vendorConfig } from './vendorConfig';
|
||||
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
businessNumber: '사업자등록번호',
|
||||
vendorName: '거래처명',
|
||||
category: '거래처 유형',
|
||||
};
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +23,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
// 새 입력 컴포넌트
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
@@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [formData.businessNumber, formData.vendorName, formData.category]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: string, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 에러 클리어
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 파일 검증 및 추가
|
||||
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
|
||||
@@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 저장 핸들러 (IntegratedDetailTemplate용)
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return { success: false, error: '입력 내용을 확인해주세요.' };
|
||||
}
|
||||
|
||||
@@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={isViewMode || disabled}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onValueChange={(val) => handleChange(field, val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 폼 콘텐츠 렌더링 (View/Edit 공통)
|
||||
const renderFormContent = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
showValidation={!isViewMode}
|
||||
error={!!validationErrors.businessNumber}
|
||||
/>
|
||||
{validationErrors.businessNumber && <p className="text-sm text-red-500">{validationErrors.businessNumber}</p>}
|
||||
</div>
|
||||
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
|
||||
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
|
||||
|
||||
@@ -49,14 +49,14 @@ import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, t
|
||||
export function CEODashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
// API 데이터 Hook (신규 6개는 백엔드 API 구현 전까지 비활성)
|
||||
// API 데이터 Hook
|
||||
const apiData = useCEODashboard({
|
||||
salesStatus: false,
|
||||
purchaseStatus: false,
|
||||
dailyProduction: false,
|
||||
unshipped: false,
|
||||
construction: false,
|
||||
dailyAttendance: false,
|
||||
salesStatus: true,
|
||||
purchaseStatus: true,
|
||||
dailyProduction: true,
|
||||
unshipped: true,
|
||||
construction: true,
|
||||
dailyAttendance: true,
|
||||
});
|
||||
|
||||
// TodayIssue API Hook (Phase 2)
|
||||
|
||||
@@ -100,52 +100,6 @@ const _originalMockData: CEODashboardData = {
|
||||
},
|
||||
],
|
||||
},
|
||||
cardManagement: {
|
||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
||||
cards: [
|
||||
{ id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' },
|
||||
{ id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'cm-cp1',
|
||||
type: 'success',
|
||||
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
|
||||
highlights: [
|
||||
{ text: '850만원', color: 'red' },
|
||||
{ text: '가지급금', color: 'red' },
|
||||
{ text: '연 4.6% 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp2',
|
||||
type: 'success',
|
||||
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
|
||||
highlights: [
|
||||
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp3',
|
||||
type: 'success',
|
||||
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
||||
highlights: [
|
||||
{ text: '불인정 항목 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp4',
|
||||
type: 'success',
|
||||
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
|
||||
highlights: [
|
||||
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
entertainment: {
|
||||
cards: [
|
||||
{ id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react';
|
||||
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift, CheckCircle2, ShieldAlert } from 'lucide-react';
|
||||
import { formatKoreanAmount } from '@/lib/utils/amount';
|
||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||
import type { CardManagementData } from '../types';
|
||||
|
||||
@@ -14,9 +16,33 @@ interface CardManagementSectionProps {
|
||||
onCardClick?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
/** subLabel에서 "미정리 N건", "미증빙 N건" 등의 건수를 파싱 */
|
||||
function parseIssueCount(subLabel?: string): number {
|
||||
if (!subLabel) return 0;
|
||||
const match = subLabel.match(/(\d+)\s*건/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 카드별 미정리/미증빙 건수 집계
|
||||
const issueStats = useMemo(() => {
|
||||
let totalCount = 0;
|
||||
let totalAmount = 0;
|
||||
const issueCards: string[] = [];
|
||||
|
||||
for (const card of data.cards) {
|
||||
const count = parseIssueCount(card.subLabel);
|
||||
if (count > 0 || card.isHighlighted) {
|
||||
totalCount += count;
|
||||
totalAmount += card.subAmount ?? 0;
|
||||
issueCards.push(card.label);
|
||||
}
|
||||
}
|
||||
return { totalCount, totalAmount, issueCards, hasIssues: totalCount > 0 };
|
||||
}, [data.cards]);
|
||||
|
||||
const handleClick = (cardId: string) => {
|
||||
if (onCardClick) {
|
||||
onCardClick(cardId);
|
||||
@@ -31,9 +57,46 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
title="가지급금 현황"
|
||||
subtitle="가지급금 관리 현황"
|
||||
>
|
||||
{data.warningBanner && (
|
||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{/* 상태 배너: 미정리 있으면 빨간 펄스, 정상이면 초록 */}
|
||||
{issueStats.hasIssues ? (
|
||||
<div className="relative overflow-hidden rounded-lg mb-4">
|
||||
{/* 펄스 배경 */}
|
||||
<div className="absolute inset-0 bg-red-500 animate-pulse opacity-20 rounded-lg" />
|
||||
<div className="relative bg-red-50 dark:bg-red-900/40 border border-red-300 dark:border-red-700 px-4 py-3 rounded-lg flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center shrink-0">
|
||||
<ShieldAlert className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-300">
|
||||
미정리 {issueStats.totalCount}건
|
||||
</p>
|
||||
<p className="text-xs text-red-600/80 dark:text-red-400/80">
|
||||
{issueStats.issueCards.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{issueStats.totalAmount > 0 && (
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-lg font-bold text-red-700 dark:text-red-300">
|
||||
{formatKoreanAmount(issueStats.totalAmount)}
|
||||
</p>
|
||||
<p className="text-[11px] text-red-500/70 dark:text-red-400/60">미정리 총액</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-2.5 rounded-lg mb-4 flex items-center gap-2.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">미정리 건 없음 — 가지급금 정상 관리 중</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 warningBanner 호환 (issueStats과 별도 메시지가 있는 경우) */}
|
||||
{data.warningBanner && issueStats.hasIssues && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium px-3 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{data.warningBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/ko/hr/attendance'); }}
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/hr/attendance-management'); }}
|
||||
className="text-white hover:bg-white/10 gap-1 text-xs"
|
||||
>
|
||||
근태관리
|
||||
|
||||
@@ -83,6 +83,12 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
}
|
||||
>
|
||||
{/* 공정 탭 */}
|
||||
{data.processes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Factory className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">오늘 등록된 작업 지시가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-4">
|
||||
{data.processes.map((process) => (
|
||||
@@ -240,6 +246,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
</CollapsibleDashboardCard>
|
||||
|
||||
|
||||
@@ -198,7 +198,8 @@ export function DocumentViewer({
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 원본 src 유지
|
||||
// 변환 실패 시 빈 이미지로 대체 (Puppeteer에서 proxy URL 요청 방지)
|
||||
clonedImg.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Bookmark, MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -20,14 +20,68 @@ import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { iconMap } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
// "시스템 대시보드" 기준 텍스트 폭 (7글자 ≈ 80px)
|
||||
const TEXT_DEFAULT_MAX = 80;
|
||||
const TEXT_EXPANDED_MAX = 200;
|
||||
const TEXT_SHRUNK_MAX = 28;
|
||||
const OVERFLOW_BTN_WIDTH = 56;
|
||||
const GAP = 6;
|
||||
|
||||
interface HeaderFavoritesBarProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
/** 별 아이콘 드롭다운 (공간 부족 / 모바일 / 태블릿) */
|
||||
function StarDropdown({
|
||||
favorites,
|
||||
className,
|
||||
onItemClick,
|
||||
}: {
|
||||
favorites: FavoriteItem[];
|
||||
className?: string;
|
||||
onItemClick: (item: FavoriteItem) => void;
|
||||
}) {
|
||||
const getIcon = (name: string) => iconMap[name] || null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Bookmark className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chipWidthsRef = useRef<number[]>([]);
|
||||
const measuredRef = useRef(false);
|
||||
const [visibleCount, setVisibleCount] = useState(favorites.length);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
// 태블릿 감지 (768~1024)
|
||||
useEffect(() => {
|
||||
@@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
// 즐겨찾기 변경 시 측정 리셋
|
||||
useEffect(() => {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}, [favorites.length]);
|
||||
|
||||
// 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋
|
||||
useEffect(() => {
|
||||
if (!isMobile && !isTablet) {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
// 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산
|
||||
useEffect(() => {
|
||||
if (isMobile || isTablet) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const calculate = () => {
|
||||
// 최초: 전체 chip 렌더 상태에서 폭 저장
|
||||
if (!measuredRef.current) {
|
||||
const chips = container.querySelectorAll<HTMLElement>('[data-chip]');
|
||||
if (chips.length === favorites.length && chips.length > 0) {
|
||||
chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth);
|
||||
measuredRef.current = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
const widths = chipWidthsRef.current;
|
||||
|
||||
// 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운
|
||||
const minChipWidth = Math.min(...widths);
|
||||
if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0);
|
||||
const hasMore = i < widths.length - 1;
|
||||
const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0;
|
||||
if (needed + reserve > containerWidth && count > 0) break;
|
||||
totalWidth = needed;
|
||||
count++;
|
||||
}
|
||||
setVisibleCount(count);
|
||||
};
|
||||
|
||||
requestAnimationFrame(calculate);
|
||||
const observer = new ResizeObserver(calculate);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: FavoriteItem) => {
|
||||
router.push(item.path);
|
||||
@@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
|
||||
if (favorites.length === 0) return null;
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
return iconMap[iconName] || null;
|
||||
};
|
||||
const getIcon = (iconName: string) => iconMap[iconName] || null;
|
||||
|
||||
// 모바일 & 태블릿: 별 아이콘 드롭다운
|
||||
if (isMobile || isTablet) {
|
||||
// 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
|
||||
isMobile
|
||||
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
|
||||
: 'w-10 h-10'
|
||||
}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StarDropdown
|
||||
favorites={favorites}
|
||||
onItemClick={handleClick}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] rounded-lg min-[320px]:rounded-xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운
|
||||
const DESKTOP_ICON_LIMIT = 8;
|
||||
|
||||
if (favorites.length > DESKTOP_ICON_LIMIT) {
|
||||
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
|
||||
if (isTablet) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-end">
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} className="w-10 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성)
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
const showStarOnly = measuredRef.current && visibleCount === 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-2">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-w-0 flex items-center justify-end gap-1.5"
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{showStarOnly ? (
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} />
|
||||
) : (
|
||||
<>
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
const isHovered = hoveredId === item.id;
|
||||
const isOtherHovered = hoveredId !== null && !isHovered;
|
||||
|
||||
const textMaxWidth = isHovered
|
||||
? TEXT_EXPANDED_MAX
|
||||
: isOtherHovered
|
||||
? TEXT_SHRUNK_MAX
|
||||
: TEXT_DEFAULT_MAX;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-chip
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
className={`rounded-full text-white h-8 flex items-center overflow-hidden ${
|
||||
isOtherHovered ? 'px-2 gap-1 bg-blue-400/70' : 'px-3 gap-1.5 bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
transition: 'all 500ms cubic-bezier(0.25, 0.8, 0.25, 1)',
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
className="text-xs whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{
|
||||
maxWidth: textMaxWidth,
|
||||
transition: 'max-width 500ms cubic-bezier(0.25, 0.8, 0.25, 1), opacity 400ms ease',
|
||||
opacity: isOtherHovered ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="rounded-full bg-blue-500/80 hover:bg-blue-600 text-white h-8 px-2.5 gap-1 flex items-center shrink-0"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">+{overflowItems.length}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
|
||||
import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
|
||||
@@ -159,7 +159,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { materialInspectionCreateConfig } from './inspectionConfig';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { getReceivings } from './actions';
|
||||
import type { InspectionCheckItem, ReceivingItem } from './types';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
@@ -81,7 +80,7 @@ export function InspectionCreate({ id }: Props) {
|
||||
const [opinion, setOpinion] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 성공 다이얼로그
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
@@ -117,15 +116,22 @@ export function InspectionCreate({ id }: Props) {
|
||||
// 대상 선택 핸들러
|
||||
const handleTargetSelect = useCallback((targetId: string) => {
|
||||
setSelectedTargetId(targetId);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 판정 변경 핸들러
|
||||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
||||
const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||||
);
|
||||
setValidationErrors([]);
|
||||
// 해당 항목의 에러 클리어
|
||||
setValidationErrors((prev) => {
|
||||
const key = `judgment_${index}`;
|
||||
if (prev[key]) {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 비고 변경 핸들러
|
||||
@@ -137,22 +143,29 @@ export function InspectionCreate({ id }: Props) {
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// 필수 필드: 검사자
|
||||
if (!inspector.trim()) {
|
||||
errors.push('검사자는 필수 입력 항목입니다.');
|
||||
errors.inspector = '검사자는 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
// 검사 항목 판정 확인
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (!item.judgment) {
|
||||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
||||
errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`;
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [inspector, inspectionItems]);
|
||||
|
||||
// 검사 저장
|
||||
@@ -214,30 +227,6 @@ export function InspectionCreate({ id }: Props) {
|
||||
|
||||
{/* 우측: 검사 정보 및 항목 */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 정보</h3>
|
||||
@@ -257,10 +246,19 @@ export function InspectionCreate({ id }: Props) {
|
||||
value={inspector}
|
||||
onChange={(e) => {
|
||||
setInspector(e.target.value);
|
||||
setValidationErrors([]);
|
||||
if (validationErrors.inspector) {
|
||||
setValidationErrors((prev) => {
|
||||
const { inspector: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="검사자명 입력"
|
||||
className={validationErrors.inspector ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.inspector && (
|
||||
<p className="text-sm text-red-500">{validationErrors.inspector}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
||||
@@ -284,39 +282,45 @@ export function InspectionCreate({ id }: Props) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspectionItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{inspectionItems.map((item, index) => {
|
||||
const judgmentErrorKey = `judgment_${index}`;
|
||||
return (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, index, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={`h-8 ${validationErrors[judgmentErrorKey] ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors[judgmentErrorKey] && (
|
||||
<p className="text-xs text-red-500 mt-1">{validationErrors[judgmentErrorKey]}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -138,7 +137,7 @@ export function ShipmentCreate() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
@@ -226,7 +225,9 @@ export function ShipmentCreate() {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
@@ -245,7 +246,13 @@ export function ShipmentCreate() {
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
@@ -289,12 +296,16 @@ export function ShipmentCreate() {
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
if (!formData.lotNo) errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -349,30 +360,6 @@ export function ShipmentCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -393,7 +380,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,6 +391,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
@@ -432,7 +420,9 @@ export function ShipmentCreate() {
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
@@ -449,7 +439,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -460,6 +450,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
@@ -748,9 +739,7 @@ export function ShipmentCreate() {
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">{error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,6 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
@@ -111,34 +103,20 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 (테이블 형태) */}
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>{detail.logisticsCompany || '-'}</TableCell>
|
||||
<TableCell>{detail.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell>{detail.tonnage || '-'}</TableCell>
|
||||
<TableCell>{detail.vehicleNo || '-'}</TableCell>
|
||||
<TableCell>{detail.driverContact || '-'}</TableCell>
|
||||
<TableCell>{detail.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('물류업체', detail.logisticsCompany)}
|
||||
{renderInfoField('입차일시', detail.arrivalDateTime)}
|
||||
{renderInfoField('구분', detail.tonnage)}
|
||||
{renderInfoField('차량번호', detail.vehicleNo)}
|
||||
{renderInfoField('기사연락처', detail.driverContact)}
|
||||
{renderInfoField('비고', detail.remarks)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -70,7 +69,7 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -121,13 +120,13 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
vat,
|
||||
totalAmount: total,
|
||||
}));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
}, [validationErrors]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -177,19 +176,6 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 (운임비용만 편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -370,11 +356,9 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
mode: string;
|
||||
errors: Record<string, string>;
|
||||
}) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,93 @@ interface PaginationMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 목데이터 (API 미응답 시 fallback) =====
|
||||
const MOCK_LIST: VehicleDispatchItem[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
dispatchNo: 'DC-20260301-001',
|
||||
shipmentNo: 'SH-20260228-012',
|
||||
lotNo: 'LOT-260228-01',
|
||||
siteName: '삼성전자 평택캠퍼스',
|
||||
orderCustomer: '삼성전자(주)',
|
||||
logisticsCompany: '한진택배',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '경기12가3456',
|
||||
driverContact: '010-1234-5678',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-03-05T09:00:00',
|
||||
status: 'draft',
|
||||
remarks: '오전 입차 요청',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
dispatchNo: 'DC-20260301-002',
|
||||
shipmentNo: 'SH-20260227-008',
|
||||
lotNo: 'LOT-260227-03',
|
||||
siteName: 'LG디스플레이 파주공장',
|
||||
orderCustomer: 'LG디스플레이(주)',
|
||||
logisticsCompany: '대한통운',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 220000,
|
||||
vat: 22000,
|
||||
totalAmount: 242000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '서울34나7890',
|
||||
driverContact: '010-9876-5432',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-03-04T14:30:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DETAIL: Record<string, VehicleDispatchDetail> = {
|
||||
'mock-1': {
|
||||
id: 'mock-1',
|
||||
dispatchNo: 'DC-20260301-001',
|
||||
shipmentNo: 'SH-20260228-012',
|
||||
lotNo: 'LOT-260228-01',
|
||||
siteName: '삼성전자 평택캠퍼스',
|
||||
orderCustomer: '삼성전자(주)',
|
||||
freightCostType: 'prepaid',
|
||||
status: 'draft',
|
||||
writer: '홍길동',
|
||||
logisticsCompany: '한진택배',
|
||||
arrivalDateTime: '2026-03-05T09:00:00',
|
||||
tonnage: '5톤',
|
||||
vehicleNo: '경기12가3456',
|
||||
driverContact: '010-1234-5678',
|
||||
remarks: '오전 입차 요청',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
},
|
||||
'mock-2': {
|
||||
id: 'mock-2',
|
||||
dispatchNo: 'DC-20260301-002',
|
||||
shipmentNo: 'SH-20260227-008',
|
||||
lotNo: 'LOT-260227-03',
|
||||
siteName: 'LG디스플레이 파주공장',
|
||||
orderCustomer: 'LG디스플레이(주)',
|
||||
freightCostType: 'collect',
|
||||
status: 'completed',
|
||||
writer: '김철수',
|
||||
logisticsCompany: '대한통운',
|
||||
arrivalDateTime: '2026-03-04T14:30:00',
|
||||
tonnage: '3.5톤',
|
||||
vehicleNo: '서울34나7890',
|
||||
driverContact: '010-9876-5432',
|
||||
remarks: '',
|
||||
supplyAmount: 220000,
|
||||
vat: 22000,
|
||||
totalAmount: 242000,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== API 응답 → 프론트 타입 변환 =====
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToListItem(data: any): VehicleDispatchItem {
|
||||
@@ -89,7 +176,7 @@ export async function getVehicleDispatches(params?: {
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
return executePaginatedAction({
|
||||
const result = await executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches', {
|
||||
search: params?.search,
|
||||
status: params?.status !== 'all' ? params?.status : undefined,
|
||||
@@ -101,6 +188,30 @@ export async function getVehicleDispatches(params?: {
|
||||
transform: transformToListItem,
|
||||
errorMessage: '배차차량 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 데이터가 없으면 목데이터 합산
|
||||
if (result.success && result.data.length === 0) {
|
||||
let mockFiltered = [...MOCK_LIST];
|
||||
if (params?.status && params.status !== 'all') {
|
||||
mockFiltered = mockFiltered.filter((m) => m.status === params.status);
|
||||
}
|
||||
if (params?.search) {
|
||||
const q = params.search.toLowerCase();
|
||||
mockFiltered = mockFiltered.filter((m) =>
|
||||
m.dispatchNo.toLowerCase().includes(q) ||
|
||||
m.lotNo?.toLowerCase().includes(q) ||
|
||||
m.siteName.toLowerCase().includes(q) ||
|
||||
m.orderCustomer.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
data: mockFiltered,
|
||||
pagination: { ...result.pagination, total: mockFiltered.length, lastPage: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
@@ -109,7 +220,7 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
return executeServerAction<
|
||||
const result = await executeServerAction<
|
||||
{ prepaid_amount: number; collect_amount: number; total_amount: number },
|
||||
VehicleDispatchStats
|
||||
>({
|
||||
@@ -121,6 +232,15 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
}),
|
||||
errorMessage: '배차차량 통계 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 통계가 모두 0이면 목데이터 기반 통계
|
||||
if (result.success && result.data && result.data.totalAmount === 0) {
|
||||
const prepaid = MOCK_LIST.filter((m) => m.freightCostType === 'prepaid').reduce((s, m) => s + m.totalAmount, 0);
|
||||
const collect = MOCK_LIST.filter((m) => m.freightCostType === 'collect').reduce((s, m) => s + m.totalAmount, 0);
|
||||
return { ...result, data: { prepaidAmount: prepaid, collectAmount: collect, totalAmount: prepaid + collect } };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
@@ -129,6 +249,11 @@ export async function getVehicleDispatchById(id: string): Promise<{
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
// 목데이터 ID인 경우 바로 반환
|
||||
if (id.startsWith('mock-') && MOCK_DETAIL[id]) {
|
||||
return { success: true, data: MOCK_DETAIL[id] };
|
||||
}
|
||||
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
transform: transformToDetail,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@@ -22,14 +22,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createWorkOrder, getProcessOptions, searchItemsForWorkOrder, type ProcessOption, type ManualItemOption } from './actions';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
import { type SalesOrder } from './types';
|
||||
import { workOrderCreateConfig } from './workOrderConfig';
|
||||
|
||||
import { useDevFill } from '@/components/dev';
|
||||
@@ -44,20 +43,6 @@ interface ManualItem {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
processId: '공정',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
type RegistrationMode = 'linked' | 'manual';
|
||||
|
||||
interface FormData {
|
||||
@@ -102,7 +87,7 @@ export function WorkOrderCreate() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
|
||||
@@ -114,6 +99,17 @@ export function WorkOrderCreate() {
|
||||
const [isSearchingItems, setIsSearchingItems] = useState(false);
|
||||
const [showItemSearch, setShowItemSearch] = useState(false);
|
||||
|
||||
// 필드 에러 클리어 헬퍼
|
||||
const clearFieldError = useCallback((field: string) => {
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 공정 옵션 로드
|
||||
useEffect(() => {
|
||||
async function loadProcessOptions() {
|
||||
@@ -173,6 +169,7 @@ export function WorkOrderCreate() {
|
||||
orderNo: order.orderNo,
|
||||
itemCount: order.itemCount,
|
||||
});
|
||||
clearFieldError('selectedOrder');
|
||||
};
|
||||
|
||||
// 수주 해제
|
||||
@@ -217,6 +214,7 @@ export function WorkOrderCreate() {
|
||||
setShowItemSearch(false);
|
||||
setItemSearchQuery('');
|
||||
setItemSearchResults([]);
|
||||
clearFieldError('items');
|
||||
};
|
||||
|
||||
// 품목 수량 변경
|
||||
@@ -232,7 +230,7 @@ export function WorkOrderCreate() {
|
||||
// 폼 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (mode === 'linked') {
|
||||
if (!formData.selectedOrder) {
|
||||
@@ -261,8 +259,8 @@ export function WorkOrderCreate() {
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
|
||||
@@ -318,35 +316,6 @@ export function WorkOrderCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 등록 방식 */}
|
||||
<section className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">등록 방식</h3>
|
||||
@@ -381,7 +350,7 @@ export function WorkOrderCreate() {
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">수주 정보</h3>
|
||||
{!formData.selectedOrder ? (
|
||||
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
|
||||
<div className={`flex items-center justify-between p-4 bg-white border rounded-lg ${validationErrors.selectedOrder ? 'border-red-500' : ''}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -448,6 +417,7 @@ export function WorkOrderCreate() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validationErrors.selectedOrder && <p className="text-sm text-red-500 mt-1">{validationErrors.selectedOrder}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -459,21 +429,29 @@ export function WorkOrderCreate() {
|
||||
<Label>발주처 *</Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, client: e.target.value });
|
||||
clearFieldError('client');
|
||||
}}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors.client ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors.client && <p className="text-sm text-red-500">{validationErrors.client}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명 *</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, projectName: e.target.value });
|
||||
clearFieldError('projectName');
|
||||
}}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors.projectName ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors.projectName && <p className="text-sm text-red-500">{validationErrors.projectName}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주번호</Label>
|
||||
@@ -506,10 +484,13 @@ export function WorkOrderCreate() {
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, processId: parseInt(value) });
|
||||
clearFieldError('processId');
|
||||
}}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -520,6 +501,7 @@ export function WorkOrderCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getSelectedProcessCode()}
|
||||
</p>
|
||||
@@ -529,8 +511,13 @@ export function WorkOrderCreate() {
|
||||
<Label>출고예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.shipmentDate}
|
||||
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
|
||||
onChange={(date) => {
|
||||
setFormData({ ...formData, shipmentDate: date });
|
||||
clearFieldError('shipmentDate');
|
||||
}}
|
||||
className={validationErrors.shipmentDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.shipmentDate && <p className="text-sm text-red-500">{validationErrors.shipmentDate}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -717,7 +704,7 @@ export function WorkOrderCreate() {
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems]);
|
||||
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems, clearFieldError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
@@ -52,17 +51,6 @@ interface EditableItem extends WorkOrderItem {
|
||||
editQuantity?: number;
|
||||
}
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
processId: '공정',
|
||||
scheduledDate: '출고예정일',
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
// 기본 정보 (읽기 전용)
|
||||
client: string;
|
||||
@@ -101,7 +89,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [deleteTargetItemId, setDeleteTargetItemId] = useState<string | null>(null);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
@@ -213,7 +201,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 폼 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.processId) {
|
||||
errors.processId = '공정을 선택해주세요';
|
||||
@@ -226,7 +214,8 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return { success: false, error: '입력 정보를 확인해주세요.' };
|
||||
}
|
||||
|
||||
@@ -344,35 +333,6 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 (기획서 4열 구성) */}
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
@@ -391,10 +351,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">공정 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, processId: parseInt(value) });
|
||||
if (validationErrors.processId) {
|
||||
setValidationErrors(prev => { const { processId: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -405,6 +370,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">구분</Label>
|
||||
@@ -442,8 +408,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">출고예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => setFormData({ ...formData, scheduledDate: date })}
|
||||
onChange={(date) => {
|
||||
setFormData({ ...formData, scheduledDate: date });
|
||||
if (validationErrors.scheduledDate) {
|
||||
setValidationErrors(prev => { const { scheduledDate: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">틀수</Label>
|
||||
|
||||
@@ -348,15 +348,28 @@ export default function WorkerScreen() {
|
||||
// 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] }
|
||||
const [stepProgressMap, setStepProgressMap] = useState<Record<string, StepProgressItem[]>>({});
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 (작업목록 + 공정목록 + 부서목록 병렬)
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getMyWorkOrders();
|
||||
if (result.success) {
|
||||
setWorkOrders(result.data);
|
||||
const [workOrderResult, processResult, deptResult] = await Promise.all([
|
||||
getMyWorkOrders(),
|
||||
getProcessList({ size: 100 }),
|
||||
getDepartments(),
|
||||
]);
|
||||
|
||||
if (workOrderResult.success) {
|
||||
setWorkOrders(workOrderResult.data);
|
||||
} else {
|
||||
toast.error(result.error || '작업 목록 조회에 실패했습니다.');
|
||||
toast.error(workOrderResult.error || '작업 목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (processResult.success && processResult.data?.items) {
|
||||
setProcessListCache(processResult.data.items);
|
||||
}
|
||||
|
||||
if (deptResult.success) {
|
||||
setDepartmentList(deptResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -369,10 +382,6 @@ export default function WorkerScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// 부서 목록 로드
|
||||
getDepartments().then((res) => {
|
||||
if (res.success) setDepartmentList(res.data);
|
||||
});
|
||||
}, [loadData]);
|
||||
|
||||
// 부서 선택 시 해당 부서 사용자 목록 로드
|
||||
@@ -455,21 +464,6 @@ export default function WorkerScreen() {
|
||||
// 공정 목록 캐시
|
||||
const [processListCache, setProcessListCache] = useState<Process[]>([]);
|
||||
|
||||
// 공정 목록 조회 (최초 1회)
|
||||
useEffect(() => {
|
||||
const fetchProcessList = async () => {
|
||||
try {
|
||||
const result = await getProcessList({ size: 100 });
|
||||
if (result.success && result.data?.items) {
|
||||
setProcessListCache(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch process list:', error);
|
||||
}
|
||||
};
|
||||
fetchProcessList();
|
||||
}, []);
|
||||
|
||||
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
|
||||
const processTabs = useMemo(() => {
|
||||
return processListCache.filter((p) => p.status === '사용중');
|
||||
@@ -1478,6 +1472,9 @@ export default function WorkerScreen() {
|
||||
</div>
|
||||
|
||||
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="list" rows={1} />
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v)}
|
||||
@@ -1708,11 +1705,12 @@ export default function WorkerScreen() {
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 고정 버튼 */}
|
||||
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
|
||||
<div className={`fixed bottom-4 left-3 right-3 px-3 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:left-auto md:right-[24px] md:px-6 ${sidebarCollapsed ? 'md:left-[113px]' : 'md:left-[304px]'}`}>
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
|
||||
<div className="flex gap-2 md:gap-3">
|
||||
{hasWipItems ? (
|
||||
<Button
|
||||
|
||||
@@ -9,11 +9,6 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useDashboardFetch } from './useDashboardFetch';
|
||||
|
||||
import {
|
||||
fetchLoanDashboard,
|
||||
fetchTaxSimulation,
|
||||
} from '@/lib/api/dashboard/endpoints';
|
||||
|
||||
import type {
|
||||
DailyReportApiResponse,
|
||||
ReceivablesApiResponse,
|
||||
@@ -36,6 +31,8 @@ import type {
|
||||
UnshippedApiResponse,
|
||||
ConstructionApiResponse,
|
||||
DailyAttendanceApiResponse,
|
||||
LoanDashboardApiResponse,
|
||||
TaxSimulationApiResponse,
|
||||
} from '@/lib/api/dashboard/types';
|
||||
|
||||
import {
|
||||
@@ -105,7 +102,19 @@ function buildEndpoint(
|
||||
// CardManagement 전용 fetch 유틸리티
|
||||
// ============================================
|
||||
|
||||
async function fetchProxyJson<T>(path: string): Promise<{ success: boolean; data: T | null }> {
|
||||
try {
|
||||
const r = await fetch(`/api/proxy${path}`);
|
||||
if (!r.ok) return { success: false, data: null };
|
||||
const json = await r.json();
|
||||
return { success: !!json.success, data: json.data ?? null };
|
||||
} catch {
|
||||
return { success: false, data: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [cardApiData, loanResponse, taxResponse] = await Promise.all([
|
||||
fetch('/api/proxy/card-transactions/summary').then(async (r) => {
|
||||
if (!r.ok) throw new Error(`API 오류: ${r.status}`);
|
||||
@@ -113,8 +122,8 @@ async function fetchCardManagementData(fallbackData?: CardManagementData) {
|
||||
if (!json.success) throw new Error(json.message || '데이터 조회 실패');
|
||||
return json.data as CardTransactionApiResponse;
|
||||
}),
|
||||
fetchLoanDashboard(),
|
||||
fetchTaxSimulation(),
|
||||
fetchProxyJson<LoanDashboardApiResponse>('/loans/dashboard'),
|
||||
fetchProxyJson<TaxSimulationApiResponse>(`/loans/tax-simulation?year=${currentYear}`),
|
||||
]);
|
||||
|
||||
const loanData = loanResponse.success ? loanResponse.data : null;
|
||||
@@ -696,32 +705,32 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
|
||||
{ initialLoading: enableStatusBoard },
|
||||
);
|
||||
const ss = useDashboardFetch<SalesStatusApiResponse, SalesStatusData>(
|
||||
enableSalesStatus ? 'sales/summary' : null,
|
||||
enableSalesStatus ? 'dashboard/sales/summary' : null,
|
||||
transformSalesStatusResponse,
|
||||
{ initialLoading: enableSalesStatus },
|
||||
);
|
||||
const ps = useDashboardFetch<PurchaseStatusApiResponse, PurchaseStatusData>(
|
||||
enablePurchaseStatus ? 'purchases/summary' : null,
|
||||
enablePurchaseStatus ? 'dashboard/purchases/summary' : null,
|
||||
transformPurchaseStatusResponse,
|
||||
{ initialLoading: enablePurchaseStatus },
|
||||
);
|
||||
const dp = useDashboardFetch<DailyProductionApiResponse, DailyProductionData>(
|
||||
enableDailyProduction ? 'production/summary' : null,
|
||||
enableDailyProduction ? 'dashboard/production/summary' : null,
|
||||
transformDailyProductionResponse,
|
||||
{ initialLoading: enableDailyProduction },
|
||||
);
|
||||
const us = useDashboardFetch<UnshippedApiResponse, UnshippedData>(
|
||||
enableUnshipped ? 'unshipped/summary' : null,
|
||||
enableUnshipped ? 'dashboard/unshipped/summary' : null,
|
||||
transformUnshippedResponse,
|
||||
{ initialLoading: enableUnshipped },
|
||||
);
|
||||
const cs = useDashboardFetch<ConstructionApiResponse, ConstructionData>(
|
||||
enableConstruction ? 'construction/summary' : null,
|
||||
enableConstruction ? 'dashboard/construction/summary' : null,
|
||||
transformConstructionResponse,
|
||||
{ initialLoading: enableConstruction },
|
||||
);
|
||||
const da = useDashboardFetch<DailyAttendanceApiResponse, DailyAttendanceData>(
|
||||
enableDailyAttendance ? 'attendance/summary' : null,
|
||||
enableDailyAttendance ? 'dashboard/attendance/summary' : null,
|
||||
transformDailyAttendanceResponse,
|
||||
{ initialLoading: enableDailyAttendance },
|
||||
);
|
||||
|
||||
@@ -1019,8 +1019,8 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
{/* 헤더 - 전체 너비 상단 고정 */}
|
||||
<header className="clean-glass px-8 py-3 mx-3 mt-3 mb-0 rounded-2xl clean-shadow relative overflow-hidden flex-shrink-0 sticky top-3 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className="flex items-center space-x-6 shrink-0">
|
||||
{/* SAM 로고 섹션 - 클릭 시 대시보드로 이동 */}
|
||||
<div
|
||||
className="flex items-center space-x-4 pr-6 border-r border-border/30 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@@ -1061,10 +1061,10 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 즐겨찾기 바로가기 */}
|
||||
<HeaderFavoritesBar isMobile={false} />
|
||||
{/* 즐겨찾기 바로가기 - 남은 공간 채움 */}
|
||||
<HeaderFavoritesBar isMobile={false} />
|
||||
|
||||
<div className="flex items-center space-x-3 shrink-0 ml-auto">
|
||||
{/* 알림 버튼 - 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1160,7 +1160,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
{/* 유저 프로필 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
<Button variant="ghost" size="sm" className="hidden md:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
<div className="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
|
||||
@@ -196,28 +196,36 @@ export function transformCardManagementResponse(
|
||||
id: 'cm1',
|
||||
label: '카드',
|
||||
amount: cardAmount,
|
||||
previousLabel: cardUnverified > 0 ? `미정리 ${cardUnverified}건` : undefined,
|
||||
subLabel: cardUnverified > 0 ? `미정리 ${cardUnverified}건` : undefined,
|
||||
subAmount: cardUnverified > 0 ? cardAmount : undefined,
|
||||
isHighlighted: cardUnverified > 0,
|
||||
},
|
||||
// cm2: 경조사
|
||||
{
|
||||
id: 'cm2',
|
||||
label: '경조사',
|
||||
amount: congratulatoryAmount,
|
||||
previousLabel: congratulatoryUnverified > 0 ? `미증빙 ${congratulatoryUnverified}건` : undefined,
|
||||
subLabel: congratulatoryUnverified > 0 ? `미증빙 ${congratulatoryUnverified}건` : undefined,
|
||||
subAmount: congratulatoryUnverified > 0 ? congratulatoryAmount : undefined,
|
||||
isHighlighted: congratulatoryUnverified > 0,
|
||||
},
|
||||
// cm3: 상품권
|
||||
{
|
||||
id: 'cm3',
|
||||
label: '상품권',
|
||||
amount: giftCertificateAmount,
|
||||
previousLabel: giftCertificateUnverified > 0 ? `미증빙 ${giftCertificateUnverified}건` : undefined,
|
||||
subLabel: giftCertificateUnverified > 0 ? `미증빙 ${giftCertificateUnverified}건` : undefined,
|
||||
subAmount: giftCertificateUnverified > 0 ? giftCertificateAmount : undefined,
|
||||
isHighlighted: giftCertificateUnverified > 0,
|
||||
},
|
||||
// cm4: 접대비
|
||||
{
|
||||
id: 'cm4',
|
||||
label: '접대비',
|
||||
amount: entertainmentAmount,
|
||||
previousLabel: entertainmentUnverified > 0 ? `미증빙 ${entertainmentUnverified}건` : undefined,
|
||||
subLabel: entertainmentUnverified > 0 ? `미증빙 ${entertainmentUnverified}건` : undefined,
|
||||
subAmount: entertainmentUnverified > 0 ? entertainmentAmount : undefined,
|
||||
isHighlighted: entertainmentUnverified > 0,
|
||||
},
|
||||
// cm_total: 총 가지급금 합계
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user