- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선 - HR: 직원 관리 및 출퇴근 설정 기능 수정 - 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables) - 알림설정: 컴포넌트 구조 단순화 및 리팩토링 - 캘린더: 헤더 및 일정 타입 개선 - 출고관리: 액션 및 타입 정의 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
546 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|