Files
sam-react-prod/src/components/business/construction/contract/ContractDetailForm.tsx
유병철 bb7e7a75e9 feat(WEB): 상세 페이지 권한 체계 통합 및 레이아웃/문서 기능 개선
권한 시스템 통합:
- BadDebtDetail, LaborDetail, PricingDetail 권한 로직 정리
- BoardDetail, ClientDetail, ItemDetail 권한 적용 개선
- ProcessDetail, StepDetail, PermissionDetail 권한 리팩토링
- ContractDetail, HandoverReport, ProgressBilling 권한 연동
- ReceivingDetail, ShipmentDetail, WorkOrderDetail 권한 적용
- InspectionDetail, OrderSalesDetail, QuoteFooterBar 권한 개선

기능 개선:
- AuthenticatedLayout 구조 리팩토링
- JointbarInspectionDocument 문서 레이아웃 개선
- PricingTableForm 폼 기능 보강
- DynamicItemForm, SectionsTab 개선
- 주문관리 상세/생산지시 페이지 개선
- VendorLedgerDetail 수정

설정:
- Claude hooks 추가 (빌드 차단, 파일 크기 체크, 미사용 import 체크)
- 품질감사 문서관리 계획 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:26:27 +09:00

547 lines
20 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Eye, Download, FileText, X, FilePlus2, Stamp } from 'lucide-react';
import { FileInput } from '@/components/ui/file-input';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { contractConfig } from './contractConfig';
import { toast } from 'sonner';
import type { ContractDetail, ContractFormData, ContractAttachment, ContractStatus } from './types';
import {
CONTRACT_STATUS_LABELS,
VAT_TYPE_OPTIONS,
getEmptyContractFormData,
contractDetailToFormData,
} from './types';
import { updateContract, deleteContract, createContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
// import { ContractDocumentModal } from './modals/ContractDocumentModal';
import { ContractDocumentModalV2 as ContractDocumentModal } from './modals/ContractDocumentModalV2';
import {
ElectronicApprovalModal,
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
interface ContractDetailFormProps {
mode: 'view' | 'edit' | 'create';
contractId: string;
initialData?: ContractDetail;
isChangeContract?: boolean; // 변경 계약서 생성 여부
}
export default function ContractDetailForm({
mode,
contractId,
initialData,
isChangeContract = false,
}: ContractDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
// 폼 데이터
const [formData, setFormData] = useState<ContractFormData>(
initialData ? contractDetailToFormData(initialData) : getEmptyContractFormData()
);
// 기존 첨부파일 (서버에서 가져온 파일)
const [existingAttachments, setExistingAttachments] = useState<ContractAttachment[]>(
initialData?.attachments || []
);
// 새로 추가된 파일
const [newAttachments, setNewAttachments] = useState<File[]>([]);
// 기존 계약서 파일 삭제 여부
const [isContractFileDeleted, setIsContractFileDeleted] = useState(false);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 모달 상태
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
// 전자결재 데이터
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
getEmptyElectronicApproval()
);
// 변경 계약서 생성 핸들러
const handleCreateChangeContract = useCallback(() => {
router.push(`/ko/construction/project/contract/create?baseContractId=${contractId}`);
}, [router, contractId]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ContractFormData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
if (isCreateMode) {
// 새 계약 생성 (변경 계약서 포함)
const result = await createContract(formData);
if (result.success && result.data) {
toast.success(isChangeContract ? '변경 계약서가 생성되었습니다.' : '계약이 생성되었습니다.');
router.push(`/ko/construction/project/contract/${result.data.id}?mode=view`);
router.refresh();
return { success: true };
}
return { success: false, error: result.error || '저장에 실패했습니다.' };
} else {
// 기존 계약 수정
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
router.push(`/ko/construction/project/contract/${contractId}?mode=view`);
router.refresh();
return { success: true };
}
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: error instanceof Error ? error.message : '저장에 실패했습니다.' };
}
}, [router, contractId, formData, isCreateMode, isChangeContract]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
const result = await deleteContract(contractId);
if (result.success) {
toast.success('계약이 삭제되었습니다.');
router.push('/ko/construction/project/contract');
router.refresh();
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: error instanceof Error ? error.message : '삭제에 실패했습니다.' };
}
}, [router, contractId]);
// 계약서 파일 선택
const handleContractFileSelect = useCallback((file: File) => {
if (file.type !== 'application/pdf') {
toast.error('PDF 파일만 업로드 가능합니다.');
return;
}
setFormData((prev) => ({ ...prev, contractFile: file }));
}, []);
// 첨부 파일 선택 (다중)
const handleAttachmentsSelect = useCallback((files: File[]) => {
setNewAttachments((prev) => [...prev, ...files]);
}, []);
// 기존 첨부파일 삭제
const handleRemoveExistingAttachment = useCallback((id: string | number) => {
setExistingAttachments((prev) => prev.filter((att) => att.id !== String(id)));
}, []);
// 새 첨부파일 삭제
const handleRemoveNewAttachment = useCallback((index: number) => {
setNewAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
// 기존 계약서 파일 삭제
const handleRemoveContractFile = useCallback(() => {
setIsContractFileDeleted(true);
setFormData((prev) => ({ ...prev, contractFile: null }));
}, []);
// 계약서 보기 핸들러
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// 파일 다운로드 핸들러
const handleFileDownload = useCallback(async (fileId: string, fileName?: string) => {
try {
await downloadFileById(parseInt(fileId), fileName);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ContractDetailForm] 다운로드 실패:', error);
toast.error('파일 다운로드에 실패했습니다.');
}
}, []);
// 전자결재 핸들러
const handleApproval = useCallback(() => {
setShowApprovalModal(true);
}, []);
// 전자결재 저장
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
setApprovalData(approval);
setShowApprovalModal(false);
toast.success('전자결재 정보가 저장되었습니다.');
}, []);
// 모드별 config 타이틀 동적 설정
// Note: IntegratedDetailTemplate이 모드에 따라 '등록'/'상세' 자동 추가
const dynamicConfig = useMemo(() => {
if (isCreateMode) {
return {
...contractConfig,
title: isChangeContract ? '변경 계약서' : '계약',
actions: {
...contractConfig.actions,
showDelete: false, // create 모드에서는 삭제 버튼 없음
},
};
}
return contractConfig;
}, [isCreateMode, isChangeContract]);
// 커스텀 헤더 액션 (view 모드에서 변경 계약서 생성, 계약서 보기, 전자결재 버튼)
const customHeaderActions = useMemo(() => {
if (!isViewMode) return null;
return (
<>
<Button variant="outline" onClick={handleCreateChangeContract} size="sm">
<FilePlus2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
<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, handleCreateChangeContract, handleViewDocument, handleApproval]);
// 폼 내용 렌더링 함수 (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.contractCode}
onChange={(e) => handleFieldChange('contractCode', 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.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약일자 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={formData.contractDate}
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<QuantityInput
value={formData.totalLocations}
onChange={(value) => handleFieldChange('totalLocations', value ?? 0)}
disabled={isViewMode}
/>
</div>
{/* 계약기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.contractStartDate}
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
disabled={isViewMode}
/>
<span>~</span>
<Input
type="date"
value={formData.contractEndDate}
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
{/* 부가세 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange('vatType', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</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
type="text"
value={formatAmount(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
}}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value as ContractStatus)}
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">
{CONTRACT_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">
{CONTRACT_STATUS_LABELS.completed}
</Label>
</div>
</RadioGroup>
</div>
{/* 비고 */}
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Textarea
value={formData.remarks}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
disabled={isViewMode}
rows={3}
/>
</div>
</div>
</CardContent>
</Card>
{/* 계약서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 파일 선택 (수정/생성 모드에서만) */}
{(isEditMode || isCreateMode) && (
<FileInput
value={formData.contractFile}
onFileSelect={handleContractFileSelect}
onFileRemove={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
accept=".pdf"
buttonText="찾기"
placeholder="PDF 파일만 업로드 가능합니다"
/>
)}
{/* 새로 선택한 파일 (view 모드에서 표시) */}
{isViewMode && formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{formData.contractFile.name}</span>
<span className="text-xs text-blue-600">( )</span>
</div>
</div>
)}
{/* 기존 계약서 파일 */}
{!isContractFileDeleted && initialData?.contractFile && !formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{initialData.contractFile.fileName}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(initialData.contractFile!.id, initialData.contractFile!.fileName)}
>
<Download className="h-4 w-4 mr-1" />
</Button>
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"
onClick={handleRemoveContractFile}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* 계약 첨부 문서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 드래그 앤 드롭 영역 */}
{(isEditMode || isCreateMode) && (
<FileDropzone
onFilesSelect={handleAttachmentsSelect}
multiple
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
/>
)}
{/* 파일 목록 */}
<FileList
files={newAttachments.map((file): NewFile => ({ file }))}
existingFiles={existingAttachments.map((att): ExistingFile => ({
id: att.id,
name: att.fileName,
size: att.fileSize,
}))}
onRemove={handleRemoveNewAttachment}
onRemoveExisting={handleRemoveExistingAttachment}
onDownload={(file) => {
const att = existingAttachments.find((a) => a.id === file.id);
if (att) handleFileDownload(att.id, att.fileName);
}}
showRemove={isEditMode || isCreateMode}
emptyMessage="첨부된 파일이 없습니다"
/>
</CardContent>
</Card>
</div>
);
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={{}}
itemId={contractId}
isLoading={false}
onSubmit={handleSubmit}
onDelete={isViewMode ? handleDelete : undefined}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 계약서 보기 모달 (특수 기능) */}
{initialData && (
<ContractDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
contract={initialData}
/>
)}
{/* 전자결재 모달 (특수 기능) */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={approvalData}
onSave={handleApprovalSave}
/>
</>
);
}