Files
sam-react-prod/src/components/business/construction/management/ConstructionDetailClient.tsx
유병철 68331be0ef feat: 회계/결재/생산/출하/대시보드 다수 개선 및 QA 수정
- 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/수정계획, 결재/품질/생산/출하 문서 추가
2026-03-09 21:06:01 +09:00

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}
/>
</>
);
}