Files
sam-react-prod/src/components/business/juil/structure-review/StructureReviewDetailForm.tsx
byeongcheolryu a938da9e22 feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링
- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선
- HR: 직원 관리 및 출퇴근 설정 기능 수정
- 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables)
- 알림설정: 컴포넌트 구조 단순화 및 리팩토링
- 캘린더: 헤더 및 일정 타입 개선
- 출고관리: 액션 및 타입 정의 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:58:10 +09:00

546 lines
18 KiB
TypeScript

'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { ClipboardCheck, Upload, X, FileText, Download, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type { StructureReview, StructureReviewStatus } from './types';
import { STRUCTURE_REVIEW_STATUS_OPTIONS } from './types';
import { deleteStructureReview } from './actions';
// 목업 거래처 목록
const MOCK_PARTNERS = [
{ value: '1', label: '거래처명A' },
{ value: '2', label: '거래처명B' },
{ value: '3', label: '거래처명C' },
];
// 목업 현장 목록
const MOCK_SITES = [
{ value: '1', label: '현장A' },
{ value: '2', label: '현장B' },
{ value: '3', label: '현장C' },
];
// 파일 타입 정의
interface ReviewFile {
id: string;
fileName: string;
fileUrl: string;
fileSize: number;
uploadedAt: string;
}
interface StructureReviewDetailFormProps {
review?: StructureReview;
mode?: 'view' | 'edit' | 'new';
}
export default function StructureReviewDetailForm({
review,
mode = 'view',
}: StructureReviewDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isNewMode = mode === 'new';
// 파일 업로드 ref
const fileInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 목데이터: 기존 구조검토 파일
const MOCK_REVIEW_FILES: ReviewFile[] = review ? [
{
id: '1',
fileName: '구조검토_보고서_최종.pdf',
fileUrl: '#',
fileSize: 3145728, // 3MB
uploadedAt: '2024-12-10T11:00:00',
},
{
id: '2',
fileName: '구조계산서_v3.xlsx',
fileUrl: '#',
fileSize: 1572864, // 1.5MB
uploadedAt: '2024-12-12T16:45:00',
},
] : [];
// 파일 목록
const [reviewFiles, setReviewFiles] = useState<ReviewFile[]>(MOCK_REVIEW_FILES);
// 폼 상태
const [formData, setFormData] = useState({
reviewNumber: review?.reviewNumber || '',
partnerId: review?.partnerId || '',
siteId: review?.siteId || '',
status: review?.status || 'pending',
reviewCompany: review?.reviewCompany || '',
reviewerName: review?.reviewerName || '',
requestDate: review?.requestDate || '',
completionDate: review?.completionDate || '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 입력 핸들러
const handleInputChange = useCallback(
(field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
},
[]
);
const handleSelectChange = useCallback((field: string) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (review?.id) {
router.push(`/ko/juil/order/structure-review/${review.id}/edit`);
}
}, [router, review?.id]);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.partnerId) {
toast.error('거래처를 선택해주세요.');
return;
}
if (!formData.siteId) {
toast.error('현장을 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('저장되었습니다.');
router.push('/ko/juil/order/structure-review');
} catch {
toast.error('저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
// 취소
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// 삭제
const handleDeleteClick = useCallback(() => {
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!review?.id) return;
setIsSubmitting(true);
try {
const result = await deleteStructureReview(review.id);
if (result.success) {
toast.success('삭제되었습니다.');
router.push('/ko/juil/order/structure-review');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
}
}, [review?.id, router]);
// 목록으로 이동
const handleGoToList = useCallback(() => {
router.push('/ko/juil/order/structure-review');
}, [router]);
// 파일 업로드 핸들러
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('파일 크기는 10MB 이하여야 합니다.');
return;
}
const doc: ReviewFile = {
id: String(Date.now()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setReviewFiles((prev) => [...prev, doc]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
// 파일 삭제 핸들러
const handleFileRemove = useCallback((docId: string) => {
setReviewFiles((prev) => prev.filter((d) => d.id !== docId));
}, []);
// 드래그 핸들러
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isViewMode) {
setIsDragging(true);
}
}, [isViewMode]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (isViewMode) return;
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
return;
}
const doc: ReviewFile = {
id: String(Date.now() + Math.random()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setReviewFiles((prev) => [...prev, doc]);
});
}, [isViewMode]);
// 타이틀 결정
const getTitle = () => {
if (isNewMode) return '구조검토 등록';
if (isEditMode) return '구조검토 수정';
return '구조검토 상세';
};
return (
<>
<PageLayout>
<PageHeader
title={getTitle()}
description="구조검토 의뢰 정보를 등록하고 관리합니다"
icon={ClipboardCheck}
actions={
isViewMode ? (
<>
<Button variant="outline" onClick={handleGoToList}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleDeleteClick}>
</Button>
<Button onClick={handleEditClick}></Button>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</>
)
}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> *</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 검토번호 */}
<div className="space-y-2">
<Label htmlFor="reviewNumber"></Label>
<Input
id="reviewNumber"
value={formData.reviewNumber}
onChange={handleInputChange('reviewNumber')}
placeholder="검토번호"
disabled={isViewMode}
/>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="partnerId"></Label>
<Select
value={formData.partnerId}
onValueChange={handleSelectChange('partnerId')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label htmlFor="siteId"></Label>
<Select
value={formData.siteId}
onValueChange={handleSelectChange('siteId')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="현장 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_SITES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={handleSelectChange('status')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STRUCTURE_REVIEW_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
(option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 구조검토 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 구조검토 회사 */}
<div className="space-y-2">
<Label htmlFor="reviewCompany"> </Label>
<Input
id="reviewCompany"
value={formData.reviewCompany}
onChange={handleInputChange('reviewCompany')}
placeholder="회사명"
disabled={isViewMode}
/>
</div>
{/* 구조검토자 */}
<div className="space-y-2">
<Label htmlFor="reviewerName"></Label>
<Input
id="reviewerName"
value={formData.reviewerName}
onChange={handleInputChange('reviewerName')}
placeholder="담당자명"
disabled={isViewMode}
/>
</div>
{/* 구조검토 의뢰일 */}
<div className="space-y-2">
<Label htmlFor="requestDate"> </Label>
<Input
id="requestDate"
type="date"
value={formData.requestDate}
onChange={handleInputChange('requestDate')}
disabled={isViewMode}
/>
</div>
{/* 구조검토 완료일 */}
<div className="space-y-2">
<Label htmlFor="completionDate"> </Label>
<Input
id="completionDate"
type="date"
value={formData.completionDate || ''}
onChange={handleInputChange('completionDate')}
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 구조검토 파일 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
/>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isViewMode
? 'bg-gray-50'
: isDragging
? 'border-primary bg-primary/5'
: 'hover:border-primary/50 cursor-pointer'
}`}
onClick={() => !isViewMode && fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className={`mx-auto h-8 w-8 mb-2 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground">
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
</p>
</div>
{/* 업로드된 파일 목록 */}
{reviewFiles.length > 0 && (
<div className="space-y-2">
{reviewFiles.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-primary" />
<div>
<p className="text-sm font-medium">{doc.fileName}</p>
<p className="text-xs text-gray-500">
{(doc.fileSize / 1024).toFixed(1)} KB
</p>
</div>
</div>{isViewMode ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
// 실제로는 doc.fileUrl로 다운로드
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-4 w-4 mr-1" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleFileRemove(doc.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}