Files
sam-react-prod/src/components/business/construction/bidding/BiddingDetailForm.tsx
유병철 c2ed71540f feat(WEB): DatePicker 공통화 및 공정관리/작업자화면 대폭 개선
DatePicker 공통화:
- date-picker.tsx 공통 컴포넌트 신규 추가
- 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일)
- DateRangeSelector 개선

공정관리:
- RuleModal 대폭 리팩토링 (-592줄 → 간소화)
- ProcessForm, StepForm 개선
- ProcessDetail 수정, actions 확장

작업자화면:
- WorkerScreen 기능 대폭 확장 (+543줄)
- WorkItemCard 개선
- types 확장

회계/인사/영업/품질:
- BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용
- EmployeeForm, VacationDialog 등 DatePicker 적용
- OrderRegistration, QuoteRegistration DatePicker 적용
- InspectionCreate, InspectionDetail DatePicker 적용

공사관리/CEO대시보드:
- BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용
- ScheduleDetailModal, TodayIssueSection 개선

기타:
- WorkOrderCreate/Edit/Detail/List 개선
- ShipmentCreate/Edit, ReceivingDetail 개선
- calendar, calendarEvents 수정
- datepicker 마이그레이션 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:48:00 +09:00

533 lines
24 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { biddingConfig } from './biddingConfig';
import type { BiddingDetail, BiddingDetailFormData } from './types';
import {
BIDDING_STATUS_OPTIONS,
BIDDING_STATUS_STYLES,
BIDDING_STATUS_LABELS,
VAT_TYPE_OPTIONS,
getEmptyBiddingDetailFormData,
biddingDetailToFormData,
} from './types';
import { updateBidding } from './actions';
import { formatNumber } from '@/utils/formatAmount';
interface BiddingDetailFormProps {
mode: 'view' | 'edit';
biddingId: string;
initialData?: BiddingDetail;
}
export default function BiddingDetailForm({
mode,
biddingId,
initialData,
}: BiddingDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
// 폼 데이터
const [formData, setFormData] = useState<BiddingDetailFormData>(
initialData ? biddingDetailToFormData(initialData) : getEmptyBiddingDetailFormData()
);
// 공과 합계 계산
const expenseTotal = useMemo(() => {
return formData.expenseItems.reduce((sum, item) => sum + item.amount, 0);
}, [formData.expenseItems]);
// 견적 상세 합계 계산 (견적 상세 페이지와 동일)
const estimateDetailTotals = useMemo(() => {
return formData.estimateDetailItems.reduce(
(acc, item) => ({
weight: acc.weight + (item.weight || 0),
area: acc.area + (item.area || 0),
steelScreen: acc.steelScreen + (item.steelScreen || 0),
caulking: acc.caulking + (item.caulking || 0),
rail: acc.rail + (item.rail || 0),
bottom: acc.bottom + (item.bottom || 0),
boxReinforce: acc.boxReinforce + (item.boxReinforce || 0),
shaft: acc.shaft + (item.shaft || 0),
painting: acc.painting + (item.painting || 0),
motor: acc.motor + (item.motor || 0),
controller: acc.controller + (item.controller || 0),
widthConstruction: acc.widthConstruction + (item.widthConstruction || 0),
heightConstruction: acc.heightConstruction + (item.heightConstruction || 0),
unitPrice: acc.unitPrice + (item.unitPrice || 0),
expense: acc.expense + (item.expense || 0),
quantity: acc.quantity + (item.quantity || 0),
cost: acc.cost + (item.cost || 0),
costExecution: acc.costExecution + (item.costExecution || 0),
marginCost: acc.marginCost + (item.marginCost || 0),
marginCostExecution: acc.marginCostExecution + (item.marginCostExecution || 0),
expenseExecution: acc.expenseExecution + (item.expenseExecution || 0),
}),
{
weight: 0,
area: 0,
steelScreen: 0,
caulking: 0,
rail: 0,
bottom: 0,
boxReinforce: 0,
shaft: 0,
painting: 0,
motor: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
unitPrice: 0,
expense: 0,
quantity: 0,
cost: 0,
costExecution: 0,
marginCost: 0,
marginCostExecution: 0,
expenseExecution: 0,
}
);
}, [formData.estimateDetailItems]);
// 저장 핸들러
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
const result = await updateBidding(biddingId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
router.push(`/ko/construction/project/bidding/${biddingId}?mode=view`);
router.refresh();
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '저장에 실패했습니다.';
toast.error(errorMsg);
return { success: false, error: errorMsg };
}
}, [router, biddingId, formData]);
// 필드 변경 핸들러
const handleFieldChange = useCallback(
(field: keyof BiddingDetailFormData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
// 폼 콘텐츠 렌더링
const renderFormContent = () => (
<div className="space-y-6">
{/* 입찰 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* 입찰번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.biddingCode} disabled className="bg-muted" />
</div>
{/* 입찰자 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.bidderName} disabled className="bg-muted" />
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.partnerName} disabled className="bg-muted" />
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.projectName} disabled className="bg-muted" />
</div>
{/* 입찰일자 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formData.biddingDate} disabled className="bg-muted" />
) : (
<DatePicker
value={formData.biddingDate}
onChange={(date) => handleFieldChange('biddingDate', date)}
/>
)}
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.totalCount} disabled className="bg-muted" />
</div>
{/* 공사기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
{isViewMode ? (
<Input
value={`${formData.constructionStartDate} ~ ${formData.constructionEndDate}`}
disabled
className="bg-muted"
/>
) : (
<>
<DatePicker
value={formData.constructionStartDate}
onChange={(date) => handleFieldChange('constructionStartDate', date)}
className="flex-1"
/>
<span>~</span>
<DatePicker
value={formData.constructionEndDate}
onChange={(date) => handleFieldChange('constructionEndDate', date)}
className="flex-1"
/>
</>
)}
</div>
</div>
{/* 부가세 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={
VAT_TYPE_OPTIONS.find((opt) => opt.value === formData.vatType)?.label ||
formData.vatType
}
disabled
className="bg-muted"
/>
) : (
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange('vatType', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 입찰금액 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formatNumber(formData.biddingAmount)}
disabled
className="bg-muted text-right font-medium"
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<div
className={`flex h-10 items-center rounded-md border px-3 ${BIDDING_STATUS_STYLES[formData.status]}`}
>
{BIDDING_STATUS_LABELS[formData.status]}
</div>
) : (
<Select
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIDDING_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
(option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
)}
</div>
{/* 투찰일 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={formData.submissionDate || '-'}
disabled
className="bg-muted"
/>
) : (
<DatePicker
value={formData.submissionDate}
onChange={(date) => handleFieldChange('submissionDate', date)}
/>
)}
</div>
{/* 확정일 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={formData.confirmDate || '-'}
disabled
className="bg-muted"
/>
) : (
<DatePicker
value={formData.confirmDate}
onChange={(date) => handleFieldChange('confirmDate', date)}
/>
)}
</div>
</div>
{/* 비고 */}
<div className="mt-4 space-y-2">
<Label></Label>
{isViewMode ? (
<Textarea
value={formData.remarks || '-'}
disabled
className="min-h-[80px] resize-none bg-muted"
/>
) : (
<Textarea
value={formData.remarks}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
placeholder="비고를 입력하세요"
className="min-h-[80px] resize-none"
/>
)}
</div>
</CardContent>
</Card>
{/* 공과 상세 섹션 (읽기 전용) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60%]"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.expenseItems.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="h-24 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
<>
{formData.expenseItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell className="text-right">
{formatNumber(item.amount)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50 font-medium">
<TableCell></TableCell>
<TableCell className="text-right">
{formatNumber(expenseTotal)}
</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 견적 상세 섹션 (읽기 전용 - 견적 상세 페이지와 동일) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto max-h-[600px] rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-white z-10">
<TableRow className="bg-gray-100">
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[90px] text-right">,</TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[90px] text-right">+</TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.estimateDetailItems.length === 0 ? (
<TableRow>
<TableCell colSpan={26} className="h-24 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
<>
{formData.estimateDetailItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="bg-gray-50">{item.name}</TableCell>
<TableCell className="bg-gray-50">{item.material}</TableCell>
<TableCell className="text-right bg-gray-50">{item.width?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.height?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.weight?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.area?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.steelScreen || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.caulking || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.rail || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.bottom || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.boxReinforce || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.shaft || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.painting || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.motor || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.controller || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.widthConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.heightConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatNumber(item.unitPrice || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.expenseRate || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.expense || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.quantity || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.cost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.costExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.marginCost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatNumber(item.marginCostExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.expenseExecution || 0)}</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
<TableCell colSpan={4} className="text-center font-bold"></TableCell>
<TableCell className="text-right">{estimateDetailTotals.weight.toFixed(2)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.area.toFixed(2)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.steelScreen)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.caulking)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.rail)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.bottom)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.boxReinforce)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.shaft)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.painting)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.motor)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.controller)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.widthConstruction)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.heightConstruction)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(estimateDetailTotals.unitPrice)}</TableCell>
<TableCell className="text-right">-</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.expense)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.quantity}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.cost)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.costExecution)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.marginCost)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(estimateDetailTotals.marginCostExecution)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.expenseExecution)}</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
// 템플릿 동적 설정
// Note: IntegratedDetailTemplate이 모드에 따라 '상세'/'수정' 자동 추가
const dynamicConfig = {
...biddingConfig,
title: '입찰',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={{}}
itemId={biddingId}
isLoading={false}
onSubmit={handleSubmit}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}