Files
sam-react-prod/src/components/business/construction/handover-report/HandoverReportDetailForm.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

694 lines
26 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X, Eye, Stamp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { QuantityInput } from '@/components/ui/quantity-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { handoverReportConfig } from './handoverReportConfig';
import { toast } from 'sonner';
import type {
HandoverReportDetail,
HandoverReportFormData,
HandoverStatus,
ConstructionManager,
} from './types';
import {
HANDOVER_STATUS_LABELS,
CONSTRUCTION_PM_OPTIONS,
MANAGER_OPTIONS,
getEmptyHandoverReportFormData,
handoverReportDetailToFormData,
} from './types';
import { updateHandoverReport, deleteHandoverReport } from './actions';
import { HandoverReportDocumentModal } from './modals/HandoverReportDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
import { formatNumber } from '@/utils/formatAmount';
interface HandoverReportDetailFormProps {
mode: 'view' | 'edit';
reportId: string;
initialData?: HandoverReportDetail;
}
export default function HandoverReportDetailForm({
mode,
reportId,
initialData,
}: HandoverReportDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<HandoverReportFormData>(
initialData ? handoverReportDetailToFormData(initialData) : getEmptyHandoverReportFormData()
);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 모달 상태
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
// 전자결재 데이터
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
getEmptyElectronicApproval()
);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof HandoverReportFormData, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
const result = await updateHandoverReport(reportId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
router.push(`/ko/construction/project/contract/handover-report/${reportId}?mode=view`);
router.refresh();
return { success: true };
}
return { success: false, error: result.error || '저장에 실패했습니다.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '저장에 실패했습니다.' };
}
}, [router, reportId, formData]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
const result = await deleteHandoverReport(reportId);
if (result.success) {
toast.success('인수인계보고서가 삭제되었습니다.');
router.push('/ko/construction/project/contract/handover-report');
router.refresh();
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '삭제에 실패했습니다.' };
}
}, [router, reportId]);
// 인수인계보고서 보기 핸들러
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// 전자결재 핸들러
const handleApproval = useCallback(() => {
setShowApprovalModal(true);
}, []);
// 전자결재 저장
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
setApprovalData(approval);
setShowApprovalModal(false);
toast.success('전자결재 정보가 저장되었습니다.');
}, []);
// 커스텀 헤더 액션 (view 모드에서 인수인계보고서 보기, 전자결재 버튼)
const customHeaderActions = useMemo(() => {
if (!isViewMode) return null;
return (
<>
<Button variant="outline" onClick={handleViewDocument} size="sm">
<Eye className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
<Button variant="outline" onClick={handleApproval} size="sm">
<Stamp className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</>
);
}, [isViewMode, handleViewDocument, handleApproval]);
// 공사담당자 추가
const handleAddManager = useCallback(() => {
const newManager: ConstructionManager = {
id: String(Date.now()),
name: '',
nonPerformanceReason: '',
};
setFormData((prev) => ({
...prev,
constructionManagers: [...prev.constructionManagers, newManager],
}));
}, []);
// 공사담당자 삭제
const handleRemoveManager = useCallback((managerId: string) => {
setFormData((prev) => ({
...prev,
constructionManagers: prev.constructionManagers.filter((m) => m.id !== managerId),
}));
}, []);
// 공사담당자 변경
const handleManagerChange = useCallback(
(managerId: string, field: keyof ConstructionManager, value: string | boolean) => {
setFormData((prev) => ({
...prev,
constructionManagers: prev.constructionManagers.map((m) =>
m.id === managerId ? { ...m, [field]: value } : m
),
}));
},
[]
);
// 장비 외 실행금액 변경
const handleEquipmentCostChange = useCallback(
(field: 'shippingCost' | 'highAltitudeWork' | 'publicExpense', value: number) => {
setFormData((prev) => ({
...prev,
externalEquipmentCost: {
...prev.externalEquipmentCost,
[field]: value,
},
}));
},
[]
);
// 계약 ITEM 비고 변경
const handleContractItemRemarkChange = useCallback((itemId: string, remark: string) => {
setFormData((prev) => ({
...prev,
contractItems: prev.contractItems.map((item) =>
item.id === itemId ? { ...item, remark } : item
),
}));
}, []);
// 폼 내용 렌더링 함수 (IntegratedDetailTemplate용)
const renderFormContent = () => (
<div className="space-y-6">
{/* 인수인계 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 보고서번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.reportNumber}
onChange={(e) => handleFieldChange('reportNumber', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약담당자 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractManagerName}
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.partnerName}
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.siteName}
onChange={(e) => handleFieldChange('siteName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약일자 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={formData.contractDate}
onChange={(date) => handleFieldChange('contractDate', date)}
disabled={isViewMode}
/>
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<QuantityInput
value={formData.totalSites}
onChange={(value) => handleFieldChange('totalSites', value ?? 0)}
disabled={isViewMode}
/>
</div>
{/* 계약기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<DatePicker
value={formData.contractStartDate}
onChange={(date) => handleFieldChange('contractStartDate', date)}
disabled={isViewMode}
/>
<span>~</span>
<DatePicker
value={formData.contractEndDate}
onChange={(date) => handleFieldChange('contractEndDate', date)}
disabled={isViewMode}
/>
</div>
</div>
{/* 계약금액 (공급가액) */}
<div className="space-y-2">
<Label> ()</Label>
<Input
type="text"
value={formatNumber(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
}}
disabled={isViewMode}
/>
</div>
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Select
value={formData.constructionPMId}
onValueChange={(value) => {
handleFieldChange('constructionPMId', value);
const pm = CONSTRUCTION_PM_OPTIONS.find((p) => p.value === value);
if (pm) {
handleFieldChange('constructionPMName', pm.label);
}
}}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{CONSTRUCTION_PM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value as HandoverStatus)}
disabled={isViewMode}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="pending" />
<Label htmlFor="pending" className="font-normal cursor-pointer">
{HANDOVER_STATUS_LABELS.pending}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="completed" id="completed" />
<Label htmlFor="completed" className="font-normal cursor-pointer">
{HANDOVER_STATUS_LABELS.completed}
</Label>
</div>
</RadioGroup>
</div>
</div>
</CardContent>
</Card>
{/* 공사담당자 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg"></CardTitle>
{isEditMode && (
<Button variant="outline" size="sm" onClick={handleAddManager}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead> </TableHead>
{isEditMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.constructionManagers.length === 0 ? (
<TableRow>
<TableCell colSpan={isEditMode ? 4 : 3} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
) : (
formData.constructionManagers.map((manager, index) => (
<TableRow key={manager.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={manager.name}
onValueChange={(value) => handleManagerChange(manager.id, 'name', value)}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="이름" />
</SelectTrigger>
<SelectContent>
{MANAGER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
manager.name
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={manager.nonPerformanceReason}
onChange={(e) =>
handleManagerChange(manager.id, 'nonPerformanceReason', e.target.value)
}
placeholder="미이행 사유 입력"
/>
) : (
manager.nonPerformanceReason || '-'
)}
</TableCell>
{isEditMode && (
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveManager(manager.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 계약 ITEM */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> ITEM</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.contractItems.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
ITEM이 .
</TableCell>
</TableRow>
) : (
formData.contractItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center">{item.no}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.product}</TableCell>
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.remark}
onChange={(e) => handleContractItemRemarkChange(item.id, e.target.value)}
placeholder="비고 입력"
/>
) : (
item.remark || '-'
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 상세 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* 2차 배관 유무 / 도장 & 코킹 유무 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 2차 배관 유무 */}
<div className="space-y-2">
<Label>2 </Label>
<div className="flex items-center gap-4">
<RadioGroup
value={formData.hasSecondaryPiping ? 'included' : 'not_included'}
onValueChange={(value) => {
handleFieldChange('hasSecondaryPiping', value === 'included');
if (value !== 'included') {
handleFieldChange('secondaryPipingNote', '');
}
}}
disabled={isViewMode}
className="flex gap-4 shrink-0"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="not_included" id="piping_not" />
<Label htmlFor="piping_not" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="included" id="piping_yes" />
<Label htmlFor="piping_yes" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
<Input
value={formData.secondaryPipingNote}
onChange={(e) => handleFieldChange('secondaryPipingNote', e.target.value)}
disabled={isViewMode || !formData.hasSecondaryPiping}
placeholder="2차 배관 내용 입력"
className={`flex-1 transition-opacity duration-200 ${
formData.hasSecondaryPiping ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
/>
</div>
</div>
{/* 도장 & 코킹 유무 */}
<div className="space-y-2">
<Label> & </Label>
<div className="flex items-center gap-4">
<RadioGroup
value={formData.hasCoating ? 'included' : 'not_included'}
onValueChange={(value) => {
handleFieldChange('hasCoating', value === 'included');
if (value !== 'included') {
handleFieldChange('coatingNote', '');
}
}}
disabled={isViewMode}
className="flex gap-4 shrink-0"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="not_included" id="coating_not" />
<Label htmlFor="coating_not" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="included" id="coating_yes" />
<Label htmlFor="coating_yes" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
<Input
value={formData.coatingNote}
onChange={(e) => handleFieldChange('coatingNote', e.target.value)}
disabled={isViewMode || !formData.hasCoating}
placeholder="도장 & 코킹 내용 입력"
className={`flex-1 transition-opacity duration-200 ${
formData.hasCoating ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
/>
</div>
</div>
</div>
{/* 장비 외 실행금액 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.shippingCost > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('shippingCost', checked ? 1500000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.highAltitudeWork > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('highAltitudeWork', checked ? 800000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.publicExpense > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('publicExpense', checked ? 10000000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
</div>
</div>
{/* 특이사항 */}
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.specialNotes}
onChange={(e) => handleFieldChange('specialNotes', e.target.value)}
disabled={isViewMode}
rows={4}
placeholder="특이사항을 입력하세요"
/>
</div>
{/* 녹음 버튼 */}
{isEditMode && (
<div className="flex justify-end">
<Button variant="outline"></Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
return (
<>
<IntegratedDetailTemplate
config={handoverReportConfig}
mode={mode}
initialData={{}}
itemId={reportId}
isLoading={false}
onSubmit={handleSubmit}
onDelete={isViewMode ? handleDelete : undefined}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 인수인계보고서 보기 모달 (특수 기능) */}
{initialData && (
<HandoverReportDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
report={initialData}
/>
)}
{/* 전자결재 모달 (특수 기능) */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={approvalData}
onSave={handleApprovalSave}
/>
</>
);
}