Files
sam-react-prod/src/components/business/construction/issue-management/IssueDetailForm.tsx
byeongcheolryu db47a15544 feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장
- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현
- 이슈관리: 현장 이슈 등록/조회 기능 추가
- 근로자현황: 일별 근로자 출역 현황 페이지 추가
- 유틸리티관리: 현장 유틸리티 관리 페이지 추가
- 기성청구: 기성청구 관리 페이지 추가
- CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선
- 발주관리: 모바일 필터 적용, 리스트 UI 개선
- 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:18:29 +09:00

693 lines
24 KiB
TypeScript

'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, List, Mic, X, Undo2, Upload } from 'lucide-react';
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 {
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 { Issue, IssueFormData, IssueImage, IssueStatus, IssueCategory, IssuePriority } from './types';
import {
ISSUE_STATUS_FORM_OPTIONS,
ISSUE_PRIORITY_FORM_OPTIONS,
ISSUE_CATEGORY_FORM_OPTIONS,
MOCK_CONSTRUCTION_NUMBERS,
MOCK_ISSUE_PARTNERS,
MOCK_ISSUE_SITES,
MOCK_ISSUE_REPORTERS,
MOCK_ISSUE_ASSIGNEES,
} from './types';
import { createIssue, updateIssue, withdrawIssue } from './actions';
interface IssueDetailFormProps {
issue?: Issue;
mode?: 'view' | 'edit' | 'create';
}
export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFormProps) {
const router = useRouter();
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
const isViewMode = mode === 'view';
// 이미지 업로드 ref
const imageInputRef = useRef<HTMLInputElement>(null);
// 철회 다이얼로그
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
// 폼 상태
const [formData, setFormData] = useState<IssueFormData>({
issueNumber: issue?.issueNumber || '',
constructionNumber: issue?.constructionNumber || '',
partnerName: issue?.partnerName || '',
siteName: issue?.siteName || '',
constructionPM: issue?.constructionPM || '',
constructionManagers: issue?.constructionManagers || '',
reporter: issue?.reporter || '',
assignee: issue?.assignee || '',
reportDate: issue?.reportDate || new Date().toISOString().split('T')[0],
resolvedDate: issue?.resolvedDate || '',
status: issue?.status || 'received',
category: issue?.category || 'material',
priority: issue?.priority || 'normal',
title: issue?.title || '',
content: issue?.content || '',
images: issue?.images || [],
});
const [isSubmitting, setIsSubmitting] = useState(false);
// 시공번호 변경 시 관련 정보 자동 채움
useEffect(() => {
if (formData.constructionNumber) {
const construction = MOCK_CONSTRUCTION_NUMBERS.find(
(c) => c.value === formData.constructionNumber
);
if (construction) {
setFormData((prev) => ({
...prev,
partnerName: construction.partnerName,
siteName: construction.siteName,
constructionPM: construction.pm,
constructionManagers: construction.managers,
}));
}
}
}, [formData.constructionNumber]);
// 담당자 지정 시 상태를 처리중으로 자동 변경
const handleAssigneeChange = useCallback((value: string) => {
setFormData((prev) => ({
...prev,
assignee: value,
// 담당자가 지정되고 현재 상태가 '접수'이면 '처리중'으로 변경
status: value && prev.status === 'received' ? 'in_progress' : prev.status,
}));
if (value && formData.status === 'received') {
toast.info('담당자가 지정되어 상태가 "처리중"으로 변경되었습니다.');
}
}, [formData.status]);
// 중요도 변경 시 긴급이면 알림 표시
const handlePriorityChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, priority: value as IssuePriority }));
if (value === 'urgent') {
toast.warning('긴급 이슈로 설정되었습니다. 공사PM과 대표에게 알림이 발송됩니다.');
}
}, []);
// 입력 핸들러
const handleInputChange = useCallback(
(field: keyof IssueFormData) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
},
[]
);
const handleSelectChange = useCallback((field: keyof IssueFormData) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (issue?.id) {
router.push(`/ko/construction/project/issue-management/${issue.id}/edit`);
}
}, [router, issue?.id]);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.title.trim()) {
toast.error('제목을 입력해주세요.');
return;
}
if (!formData.constructionNumber) {
toast.error('시공번호를 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
if (isCreateMode) {
const result = await createIssue({
issueNumber: `ISS-${Date.now()}`,
constructionNumber: formData.constructionNumber,
partnerName: formData.partnerName,
siteName: formData.siteName,
constructionPM: formData.constructionPM,
constructionManagers: formData.constructionManagers,
category: formData.category,
title: formData.title,
content: formData.content,
reporter: formData.reporter,
reportDate: formData.reportDate,
resolvedDate: formData.resolvedDate || null,
assignee: formData.assignee,
priority: formData.priority,
status: formData.status,
images: formData.images,
});
if (result.success) {
toast.success('이슈가 등록되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 등록에 실패했습니다.');
}
} else {
const result = await updateIssue(issue!.id, {
constructionNumber: formData.constructionNumber,
partnerName: formData.partnerName,
siteName: formData.siteName,
constructionPM: formData.constructionPM,
constructionManagers: formData.constructionManagers,
category: formData.category,
title: formData.title,
content: formData.content,
reporter: formData.reporter,
reportDate: formData.reportDate,
resolvedDate: formData.resolvedDate || null,
assignee: formData.assignee,
priority: formData.priority,
status: formData.status,
images: formData.images,
});
if (result.success) {
toast.success('이슈가 수정되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 수정에 실패했습니다.');
}
}
} catch {
toast.error('저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [formData, isCreateMode, issue, router]);
// 취소
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// 철회
const handleWithdraw = useCallback(async () => {
if (!issue?.id) return;
try {
const result = await withdrawIssue(issue.id);
if (result.success) {
toast.success('이슈가 철회되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 철회에 실패했습니다.');
}
} catch {
toast.error('이슈 철회에 실패했습니다.');
} finally {
setWithdrawDialogOpen(false);
}
}, [issue?.id, router]);
// 이미지 업로드 핸들러
const handleImageUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: IssueImage[] = Array.from(files).map((file, index) => ({
id: `img-${Date.now()}-${index}`,
url: URL.createObjectURL(file),
fileName: file.name,
uploadedAt: new Date().toISOString(),
}));
setFormData((prev) => ({
...prev,
images: [...prev.images, ...newImages],
}));
toast.success(`${files.length}개의 이미지가 추가되었습니다.`);
// 입력 초기화
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
}, []);
// 이미지 삭제
const handleImageRemove = useCallback((imageId: string) => {
setFormData((prev) => ({
...prev,
images: prev.images.filter((img) => img.id !== imageId),
}));
toast.success('이미지가 삭제되었습니다.');
}, []);
// 녹음 버튼 (UI만)
const handleRecordClick = useCallback(() => {
toast.info('녹음 기능은 준비 중입니다.');
}, []);
// 읽기 전용 여부
const isReadOnly = isViewMode;
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '이슈 등록' : '이슈 상세'}
description="이슈를 등록하고 관리합니다"
icon={AlertTriangle}
actions={
isViewMode ? (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => router.push('/ko/construction/project/issue-management')}
>
<List className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setWithdrawDialogOpen(true)}
className="text-orange-600 border-orange-300 hover:bg-orange-50"
>
<Undo2 className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleEditClick}></Button>
</div>
) : (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => router.push('/ko/construction/project/issue-management')}
>
<List className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</div>
)
}
/>
<div className="space-y-6">
{/* 이슈 정보 카드 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 이슈번호 */}
<div className="space-y-2">
<Label htmlFor="issueNumber"></Label>
<Input
id="issueNumber"
value={formData.issueNumber || (isCreateMode ? '자동 생성' : '')}
disabled
className="bg-muted"
/>
</div>
{/* 시공번호 */}
<div className="space-y-2">
<Label htmlFor="constructionNumber"></Label>
<Select
value={formData.constructionNumber}
onValueChange={handleSelectChange('constructionNumber')}
disabled={isReadOnly}
>
<SelectTrigger id="constructionNumber">
<SelectValue placeholder="시공번호 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_CONSTRUCTION_NUMBERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="partnerName"></Label>
<Select
value={formData.partnerName}
onValueChange={handleSelectChange('partnerName')}
disabled={isReadOnly}
>
<SelectTrigger id="partnerName">
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장 */}
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Select
value={formData.siteName}
onValueChange={handleSelectChange('siteName')}
disabled={isReadOnly}
>
<SelectTrigger id="siteName">
<SelectValue placeholder="현장 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_SITES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 공사PM (자동) */}
<div className="space-y-2">
<Label htmlFor="constructionPM">PM</Label>
<Input
id="constructionPM"
value={formData.constructionPM}
disabled
className="bg-muted"
placeholder="시공번호 선택 시 자동 입력"
/>
</div>
{/* 공사담당자 (자동) */}
<div className="space-y-2">
<Label htmlFor="constructionManagers"></Label>
<Input
id="constructionManagers"
value={formData.constructionManagers}
disabled
className="bg-muted"
placeholder="시공번호 선택 시 자동 입력"
/>
</div>
{/* 보고자 */}
<div className="space-y-2">
<Label htmlFor="reporter"></Label>
<Select
value={formData.reporter}
onValueChange={handleSelectChange('reporter')}
disabled={isReadOnly}
>
<SelectTrigger id="reporter">
<SelectValue placeholder="보고자 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_REPORTERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 담당자 */}
<div className="space-y-2">
<Label htmlFor="assignee"></Label>
<Select
value={formData.assignee}
onValueChange={handleAssigneeChange}
disabled={isReadOnly}
>
<SelectTrigger id="assignee">
<SelectValue placeholder="담당자 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_ASSIGNEES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 이슈보고일 */}
<div className="space-y-2">
<Label htmlFor="reportDate"></Label>
<Input
id="reportDate"
type="date"
value={formData.reportDate}
onChange={handleInputChange('reportDate')}
disabled={isReadOnly}
/>
</div>
{/* 이슈해결일 */}
<div className="space-y-2">
<Label htmlFor="resolvedDate"></Label>
<Input
id="resolvedDate"
type="date"
value={formData.resolvedDate}
onChange={handleInputChange('resolvedDate')}
disabled={isReadOnly}
/>
</div>
{/* 상태 */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => handleSelectChange('status')(value as IssueStatus)}
disabled={isReadOnly}
>
<SelectTrigger id="status" className="w-full md:w-[200px]">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_STATUS_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 이슈 보고 카드 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 구분 & 중요도 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
value={formData.category}
onValueChange={(value) => handleSelectChange('category')(value as IssueCategory)}
disabled={isReadOnly}
>
<SelectTrigger id="category">
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_CATEGORY_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 중요도 */}
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<Select
value={formData.priority}
onValueChange={handlePriorityChange}
disabled={isReadOnly}
>
<SelectTrigger id="priority">
<SelectValue placeholder="중요도 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_PRIORITY_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={formData.title}
onChange={handleInputChange('title')}
placeholder="제목을 입력하세요"
disabled={isReadOnly}
/>
</div>
{/* 내용 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="content"></Label>
{!isReadOnly && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecordClick}
>
<Mic className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<Textarea
id="content"
value={formData.content}
onChange={handleInputChange('content')}
placeholder="내용을 입력하세요"
rows={6}
disabled={isReadOnly}
/>
</div>
</div>
</CardContent>
</Card>
{/* 사진 카드 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent className="pt-4 space-y-4">
{/* 업로드 버튼 */}
{!isReadOnly && (
<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
ref={imageInputRef}
type="file"
accept="image/*"
multiple
onChange={handleImageUpload}
className="hidden"
/>
</label>
</div>
)}
{/* 업로드된 사진 목록 */}
{formData.images.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{formData.images.map((image) => (
<div key={image.id} className="relative group">
<img
src={image.url}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border"
/>
{!isReadOnly && (
<button
type="button"
onClick={() => handleImageRemove(image.id)}
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
<div className="text-xs text-muted-foreground truncate mt-1">
{image.fileName}
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-4">
.
</div>
)}
</CardContent>
</Card>
</div>
{/* 철회 확인 다이얼로그 */}
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleWithdraw}
className="bg-orange-600 hover:bg-orange-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}