- BadDebtCollection, BillManagement, CardTransaction, TaxInvoice 회계 개선 - VendorManagement/VendorDetailClient 소폭 추가 - DocumentCreate/DraftBox 결재 기능 개선 - WorkOrder Create/Detail/Edit, ShipmentEdit 생산/출하 개선 - CEO 대시보드: PurchaseStatusSection, receivable/status-issue transformer 정비 - dashboard types/invalidation 확장 - LoginPage, Sidebar, HeaderFavoritesBar 레이아웃 수정 - QMS 페이지, StockStatusDetail, OrderRegistration 소폭 수정 - AttendanceManagement, VacationManagement HR 수정 - ConstructionDetailClient 건설 상세 개선 - claudedocs: 주간 구현내역, 대시보드 QA/수정계획, 결재/품질/생산/출하 문서 추가
737 lines
27 KiB
TypeScript
737 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
|
import dynamic from 'next/dynamic';
|
|
import { useRouter } from 'next/navigation';
|
|
import { getTodayString, formatDate } from '@/lib/utils/date';
|
|
import { Plus, Trash2, FileText, Upload } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
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 { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { constructionConfig } from './constructionConfig';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
getConstructionManagementDetail,
|
|
updateConstructionManagementDetail,
|
|
completeConstruction,
|
|
} from './actions';
|
|
import { getOrderDetailFull } from '../order-management/actions';
|
|
const OrderDocumentModal = dynamic(
|
|
() => import('../order-management/modals/OrderDocumentModal').then(mod => ({ default: mod.OrderDocumentModal })),
|
|
);
|
|
import type {
|
|
ConstructionManagementDetail,
|
|
ConstructionDetailFormData,
|
|
WorkerInfo,
|
|
WorkProgressInfo,
|
|
PhotoInfo,
|
|
} from './types';
|
|
import type { OrderDetail } from '../order-management/types';
|
|
import {
|
|
MOCK_EMPLOYEES,
|
|
MOCK_CM_WORK_TEAM_LEADERS,
|
|
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
|
|
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
|
|
} from './types';
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
|
|
interface ConstructionDetailClientProps {
|
|
id: string;
|
|
mode: 'view' | 'edit';
|
|
}
|
|
|
|
export default function ConstructionDetailClient({ id, mode }: ConstructionDetailClientProps) {
|
|
const router = useRouter();
|
|
|
|
// 모드 플래그
|
|
const isViewMode = mode === 'view';
|
|
const isEditMode = mode === 'edit';
|
|
|
|
// 데이터 상태
|
|
const [detail, setDetail] = useState<ConstructionManagementDetail | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// 폼 데이터 상태
|
|
const [formData, setFormData] = useState<ConstructionDetailFormData>({
|
|
workTeamLeader: '',
|
|
workerInfoList: [],
|
|
workProgressList: [],
|
|
workLogContent: '',
|
|
photos: [],
|
|
isIssueReported: false,
|
|
});
|
|
|
|
// 발주서 모달 상태
|
|
const [showOrderModal, setShowOrderModal] = useState(false);
|
|
const [orderData, setOrderData] = useState<OrderDetail | null>(null);
|
|
|
|
// 시공 완료 다이얼로그 상태
|
|
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
|
|
|
|
// 데이터 로드
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const result = await getConstructionManagementDetail(id);
|
|
|
|
if (result.success && result.data) {
|
|
setDetail(result.data);
|
|
setFormData({
|
|
workTeamLeader: result.data.workTeamLeader,
|
|
workerInfoList: result.data.workerInfoList,
|
|
workProgressList: result.data.workProgressList,
|
|
workLogContent: result.data.workLogContent,
|
|
photos: result.data.photos,
|
|
isIssueReported: result.data.isIssueReported,
|
|
});
|
|
} else {
|
|
toast.error(result.error || '시공 정보를 불러올 수 없습니다.');
|
|
router.push('/ko/construction/project/construction-management');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load construction detail:', error);
|
|
toast.error('시공 정보를 불러올 수 없습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}, [id, router]);
|
|
|
|
|
|
// 작업반장 변경
|
|
const handleWorkTeamLeaderChange = (value: string) => {
|
|
setFormData((prev) => ({ ...prev, workTeamLeader: value }));
|
|
};
|
|
|
|
// 작업자 정보 추가
|
|
const handleAddWorkerInfo = () => {
|
|
const newWorkerInfo: WorkerInfo = {
|
|
id: `worker-${Date.now()}`,
|
|
workDate: getTodayString(),
|
|
workers: [],
|
|
};
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workerInfoList: [...prev.workerInfoList, newWorkerInfo],
|
|
}));
|
|
};
|
|
|
|
// 작업자 정보 삭제
|
|
const handleDeleteWorkerInfo = (workerId: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workerInfoList: prev.workerInfoList.filter((w) => w.id !== workerId),
|
|
}));
|
|
};
|
|
|
|
// 작업자 정보 변경
|
|
const handleWorkerInfoChange = (
|
|
workerId: string,
|
|
field: keyof WorkerInfo,
|
|
value: string | string[]
|
|
) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workerInfoList: prev.workerInfoList.map((w) =>
|
|
w.id === workerId ? { ...w, [field]: value } : w
|
|
),
|
|
}));
|
|
};
|
|
|
|
// 공과 정보 추가
|
|
const handleAddWorkProgress = () => {
|
|
const newProgress: WorkProgressInfo = {
|
|
id: `progress-${Date.now()}`,
|
|
scheduleDate: '',
|
|
workName: '',
|
|
};
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workProgressList: [...prev.workProgressList, newProgress],
|
|
}));
|
|
};
|
|
|
|
// 공과 정보 삭제
|
|
const handleDeleteWorkProgress = (progressId: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workProgressList: prev.workProgressList.filter((p) => p.id !== progressId),
|
|
}));
|
|
};
|
|
|
|
// 공과 정보 변경
|
|
const handleWorkProgressChange = (
|
|
progressId: string,
|
|
field: keyof WorkProgressInfo,
|
|
value: string
|
|
) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
workProgressList: prev.workProgressList.map((p) =>
|
|
p.id === progressId ? { ...p, [field]: value } : p
|
|
),
|
|
}));
|
|
};
|
|
|
|
// 작업일지 변경
|
|
const handleWorkLogChange = (value: string) => {
|
|
setFormData((prev) => ({ ...prev, workLogContent: value }));
|
|
};
|
|
|
|
// 사진 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
|
|
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
|
|
// 임시 목업: 파일 정보를 photos에 추가
|
|
const newPhotos: PhotoInfo[] = Array.from(files).map((file, index) => ({
|
|
id: `photo-${Date.now()}-${index}`,
|
|
url: URL.createObjectURL(file),
|
|
name: file.name,
|
|
uploadedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
photos: [...prev.photos, ...newPhotos],
|
|
}));
|
|
};
|
|
|
|
// 사진 삭제
|
|
const handleDeletePhoto = (photoId: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
photos: prev.photos.filter((p) => p.id !== photoId),
|
|
}));
|
|
};
|
|
|
|
// 발주서 보기
|
|
const handleViewOrder = async () => {
|
|
if (!detail?.orderId) return;
|
|
|
|
try {
|
|
const result = await getOrderDetailFull(detail.orderId);
|
|
if (result.success && result.data) {
|
|
setOrderData(result.data);
|
|
setShowOrderModal(true);
|
|
} else {
|
|
toast.error('발주서 정보를 불러올 수 없습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load order detail:', error);
|
|
toast.error('발주서 정보를 불러올 수 없습니다.');
|
|
}
|
|
};
|
|
|
|
// 저장 핸들러 (IntegratedDetailTemplate용)
|
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
try {
|
|
const result = await updateConstructionManagementDetail(id, formData);
|
|
if (result.success) {
|
|
invalidateDashboard('construction');
|
|
toast.success('저장되었습니다.');
|
|
return { success: true };
|
|
} else {
|
|
return { success: false, error: result.error || '저장에 실패했습니다.' };
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save:', error);
|
|
return { success: false, error: '저장에 실패했습니다.' };
|
|
}
|
|
}, [id, formData]);
|
|
|
|
// 시공 완료 버튼 활성화 조건: 작업일지 + 사진 모두 있어야 함
|
|
const canComplete =
|
|
detail?.status === 'in_progress' &&
|
|
formData.workLogContent.trim() !== '' &&
|
|
formData.photos.length > 0;
|
|
|
|
// 시공 완료 처리
|
|
const handleComplete = async () => {
|
|
try {
|
|
const result = await completeConstruction(id);
|
|
if (result.success) {
|
|
invalidateDashboard('construction');
|
|
toast.success('시공이 완료되었습니다.');
|
|
router.push('/ko/construction/project/construction-management');
|
|
} else {
|
|
toast.error(result.error || '시공 완료 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to complete:', error);
|
|
toast.error('시공 완료 처리에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
// 로딩 상태
|
|
if (isLoading || !detail) {
|
|
return (
|
|
<IntegratedDetailTemplate
|
|
config={constructionConfig}
|
|
mode={mode}
|
|
initialData={{}}
|
|
itemId={id}
|
|
isLoading={true}
|
|
onSubmit={handleSubmit}
|
|
renderView={() => null}
|
|
renderForm={() => null}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 폼 내용 렌더링 함수 (IntegratedDetailTemplate용)
|
|
const renderFormContent = () => (
|
|
<div className="space-y-6">
|
|
{/* 시공 정보 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<CardTitle className="text-base font-medium">시공 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{/* 시공번호 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">시공번호</label>
|
|
<div className="font-medium">{detail.constructionNumber}</div>
|
|
</div>
|
|
|
|
{/* 현장 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">현장</label>
|
|
<div className="font-medium">{detail.siteName}</div>
|
|
</div>
|
|
|
|
{/* 시공투입일 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">시공투입일</label>
|
|
<div className="font-medium">{formatDate(detail.constructionStartDate)}</div>
|
|
</div>
|
|
|
|
{/* 시공완료일 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">시공완료일</label>
|
|
<div className="font-medium">{formatDate(detail.constructionEndDate)}</div>
|
|
</div>
|
|
|
|
{/* 작업반장 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">작업반장</label>
|
|
{isViewMode ? (
|
|
<div className="font-medium">{formData.workTeamLeader || '-'}</div>
|
|
) : (
|
|
<Select
|
|
value={formData.workTeamLeader}
|
|
onValueChange={handleWorkTeamLeaderChange}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MOCK_CM_WORK_TEAM_LEADERS.map((leader) => (
|
|
<SelectItem key={leader.value} value={leader.label}>
|
|
{leader.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상태 */}
|
|
<div className="space-y-1">
|
|
<label className="text-sm text-muted-foreground">상태</label>
|
|
<div>
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[detail.status]}`}
|
|
>
|
|
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[detail.status]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 작업자 정보 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-medium">작업자 정보</CardTitle>
|
|
{isEditMode && (
|
|
<Button variant="outline" size="sm" onClick={handleAddWorkerInfo}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
{formData.workerInfoList.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-8">
|
|
{isViewMode ? '등록된 작업자 정보가 없습니다.' : '작업자 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
|
<th className="text-left py-2 px-3 text-sm font-medium w-40">작업일</th>
|
|
<th className="text-left py-2 px-3 text-sm font-medium">작업자</th>
|
|
{isEditMode && (
|
|
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{formData.workerInfoList.map((worker, index) => (
|
|
<tr key={worker.id} className="border-b">
|
|
<td className="py-2 px-3">{index + 1}</td>
|
|
<td className="py-2 px-3">
|
|
{isViewMode ? (
|
|
<span>{worker.workDate || '-'}</span>
|
|
) : (
|
|
<DatePicker
|
|
value={worker.workDate}
|
|
onChange={(date) =>
|
|
handleWorkerInfoChange(worker.id, 'workDate', date)
|
|
}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{isViewMode ? (
|
|
<span>
|
|
{worker.workers.length > 0
|
|
? worker.workers
|
|
.map((w) => MOCK_EMPLOYEES.find((e) => e.value === w)?.label || w)
|
|
.join(', ')
|
|
: '-'}
|
|
</span>
|
|
) : (
|
|
<MultiSelectCombobox
|
|
options={MOCK_EMPLOYEES}
|
|
value={worker.workers}
|
|
onChange={(value) =>
|
|
handleWorkerInfoChange(worker.id, 'workers', value)
|
|
}
|
|
placeholder="작업자 선택"
|
|
className="w-full"
|
|
/>
|
|
)}
|
|
</td>
|
|
{isEditMode && (
|
|
<td className="py-2 px-3 text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteWorkerInfo(worker.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 공과 정보 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base font-medium">공과 정보</CardTitle>
|
|
{isEditMode && (
|
|
<Button variant="outline" size="sm" onClick={handleAddWorkProgress}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
{formData.workProgressList.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-8">
|
|
{isViewMode ? '등록된 공과 정보가 없습니다.' : '공과 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
|
<th className="text-left py-2 px-3 text-sm font-medium w-48">일정</th>
|
|
<th className="text-left py-2 px-3 text-sm font-medium">공과명</th>
|
|
{isEditMode && (
|
|
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{formData.workProgressList.map((progress, index) => (
|
|
<tr key={progress.id} className="border-b">
|
|
<td className="py-2 px-3">{index + 1}</td>
|
|
<td className="py-2 px-3">
|
|
{isViewMode ? (
|
|
<span>{progress.scheduleDate || '-'}</span>
|
|
) : (
|
|
<DateTimePicker
|
|
value={progress.scheduleDate}
|
|
onChange={(val) =>
|
|
handleWorkProgressChange(
|
|
progress.id,
|
|
'scheduleDate',
|
|
val
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
{isViewMode ? (
|
|
<span>{progress.workName || '-'}</span>
|
|
) : (
|
|
<Input
|
|
type="text"
|
|
value={progress.workName}
|
|
onChange={(e) =>
|
|
handleWorkProgressChange(progress.id, 'workName', e.target.value)
|
|
}
|
|
placeholder="공과명을 입력하세요"
|
|
className="w-full"
|
|
/>
|
|
)}
|
|
</td>
|
|
{isEditMode && (
|
|
<td className="py-2 px-3 text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteWorkProgress(progress.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 발주서 영역 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<CardTitle className="text-base font-medium">발주서</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
<button
|
|
type="button"
|
|
onClick={handleViewOrder}
|
|
className="text-primary hover:underline font-medium"
|
|
>
|
|
{detail.orderNumber}
|
|
</button>
|
|
<span className="text-muted-foreground text-sm">(클릭하여 발주서 보기)</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 이슈 목록 / 이슈 보고 - 카드 2개 형태 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* 이슈 목록 카드 */}
|
|
<Card
|
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
onClick={() => router.push(`/ko/construction/project/issue-management?orderId=${detail.orderId}`)}
|
|
>
|
|
<CardContent className="pt-6 pb-6">
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-medium">이슈 목록</h3>
|
|
<p className="text-3xl font-bold">{detail.issueCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 이슈 보고 카드 */}
|
|
<Card
|
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
onClick={() => router.push(`/ko/construction/project/issue-management?mode=new&orderId=${detail.orderId}`)}
|
|
>
|
|
<CardContent className="pt-6 pb-6">
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-medium">이슈 보고</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
이슈를 등록하시면 공사담당자가 검토합니다
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 작업일지 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<CardTitle className="text-base font-medium">작업일지</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
{isViewMode ? (
|
|
<div className="min-h-[100px] whitespace-pre-wrap">
|
|
{formData.workLogContent || '등록된 작업일지가 없습니다.'}
|
|
</div>
|
|
) : (
|
|
<Textarea
|
|
value={formData.workLogContent}
|
|
onChange={(e) => handleWorkLogChange(e.target.value)}
|
|
placeholder="작업일지를 입력하세요"
|
|
className="min-h-[150px]"
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 사진 */}
|
|
<Card>
|
|
<CardHeader className="border-b pb-4">
|
|
<CardTitle className="text-base font-medium">사진</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-4 space-y-4">
|
|
{/* 업로드 버튼 - edit 모드에서만 */}
|
|
{isEditMode && (
|
|
<div>
|
|
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
|
|
<Upload className="h-4 w-4" />
|
|
<span className="text-sm">사진 업로드</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handlePhotoUpload}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* 업로드된 사진 목록 */}
|
|
{formData.photos.length > 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{formData.photos.map((photo) => (
|
|
<div key={photo.id} className="relative group">
|
|
<img
|
|
src={photo.url}
|
|
alt={photo.name}
|
|
className="w-full h-32 object-cover rounded-lg border"
|
|
/>
|
|
{isEditMode && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeletePhoto(photo.id)}
|
|
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
<div className="text-xs text-muted-foreground truncate mt-1">
|
|
{photo.name}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{formData.photos.length === 0 && (
|
|
<div className="text-center text-muted-foreground py-4">
|
|
업로드된 사진이 없습니다.
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 시공 완료 버튼 - edit 모드에서만 */}
|
|
{isEditMode && detail.status === 'in_progress' && (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="lg"
|
|
onClick={() => setShowCompleteDialog(true)}
|
|
disabled={!canComplete}
|
|
>
|
|
시공 완료
|
|
</Button>
|
|
{!canComplete && (
|
|
<span className="ml-3 text-sm text-muted-foreground self-center">
|
|
* 작업일지와 사진을 모두 등록해야 시공 완료가 가능합니다.
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// 동적 config 설정
|
|
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
|
|
// view 모드에서 "시공 상세"로 표시하려면 직접 설정 필요
|
|
const dynamicConfig = {
|
|
...constructionConfig,
|
|
title: isViewMode ? '시공 상세' : '시공',
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<IntegratedDetailTemplate
|
|
config={dynamicConfig}
|
|
mode={mode}
|
|
initialData={{}}
|
|
itemId={id}
|
|
isLoading={false}
|
|
onSubmit={handleSubmit}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
|
|
{/* 발주서 모달 (특수 기능) */}
|
|
{orderData && (
|
|
<OrderDocumentModal
|
|
open={showOrderModal}
|
|
onOpenChange={setShowOrderModal}
|
|
order={orderData}
|
|
/>
|
|
)}
|
|
|
|
{/* 시공 완료 확인 다이얼로그 (특수 기능) */}
|
|
<ConfirmDialog
|
|
open={showCompleteDialog}
|
|
onOpenChange={setShowCompleteDialog}
|
|
title="시공 완료"
|
|
description="시공을 완료하시겠습니까? 완료 후에는 상태를 변경할 수 없습니다."
|
|
confirmText="완료"
|
|
variant="warning"
|
|
onConfirm={handleComplete}
|
|
/>
|
|
</>
|
|
);
|
|
} |