Files
sam-react-prod/src/components/business/construction/issue-management/IssueDetailForm.tsx
권혁성 f9dafbc02c fix(date): UTC 기반 날짜를 로컬 타임존으로 변경
- 공통 날짜 유틸리티 함수 추가 (src/utils/date.ts)
  - getLocalDateString(): 로컬 타임존 YYYY-MM-DD 포맷
  - getTodayString(): 오늘 날짜 반환
  - getDateAfterDays(): N일 후 날짜 계산
  - formatDateForInput(): API 응답 → input 포맷 변환

- toISOString().split('T')[0] 패턴을 공통 함수로 교체
  - 견적: QuoteRegistration, QuoteRegistrationV2, types
  - 건설: contract, site-briefings, estimates, bidding types
  - 건설: IssueDetailForm, ConstructionDetailClient, ProjectEndDialog
  - 자재: InspectionCreate, ReceivingReceiptContent, StockStatus/mockData
  - 품질: InspectionManagement/mockData
  - 기타: PricingFormClient, ShipmentCreate, PurchaseOrderDocument
  - 기타: MainDashboard, attendance/actions, dev/generators

문제: toISOString()은 UTC 기준이라 한국(UTC+9)에서 오전 9시 이전에
      전날 날짜가 표시되는 버그 발생
해결: 로컬 타임존 기반 날짜 포맷 함수로 통일
2026-01-26 17:15:22 +09:00

628 lines
23 KiB
TypeScript

'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getTodayString } from '@/utils/date';
import { Mic, X, 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 { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { issueConfig } from './issueConfig';
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 isCreateMode = mode === 'create';
const isViewMode = mode === 'view';
// 이미지 업로드 ref
const imageInputRef = useRef<HTMLInputElement>(null);
// 폼 상태
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 || getTodayString(),
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 handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!formData.title.trim()) {
toast.error('제목을 입력해주세요.');
return { success: false, error: '제목을 입력해주세요.' };
}
if (!formData.constructionNumber) {
toast.error('시공번호를 선택해주세요.');
return { success: false, error: '시공번호를 선택해주세요.' };
}
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');
return { success: true };
} else {
toast.error(result.error || '이슈 등록에 실패했습니다.');
return { success: false, 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');
return { success: true };
} else {
toast.error(result.error || '이슈 수정에 실패했습니다.');
return { success: false, error: result.error || '이슈 수정에 실패했습니다.' };
}
}
} catch {
toast.error('저장에 실패했습니다.');
return { success: false, error: '저장에 실패했습니다.' };
} finally {
setIsSubmitting(false);
}
}, [formData, isCreateMode, issue, router]);
// 철회 (onDelete로 매핑)
const handleWithdraw = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!issue?.id) return { success: false, error: '이슈 ID가 없습니다.' };
try {
const result = await withdrawIssue(issue.id);
if (result.success) {
toast.success('이슈가 철회되었습니다.');
router.push('/ko/construction/project/issue-management');
return { success: true };
} else {
toast.error(result.error || '이슈 철회에 실패했습니다.');
return { success: false, error: result.error || '이슈 철회에 실패했습니다.' };
}
} catch {
toast.error('이슈 철회에 실패했습니다.');
return { success: false, error: '이슈 철회에 실패했습니다.' };
}
}, [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;
// 폼 콘텐츠 렌더링
const renderFormContent = () => (
<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>
);
// 템플릿 모드 및 동적 설정
// Note: IntegratedDetailTemplate이 모드에 따라 '등록'/'상세' 자동 추가
const templateMode = isCreateMode ? 'create' : mode;
const dynamicConfig = {
...issueConfig,
title: isViewMode ? '이슈 상세' : '이슈',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={issue?.id || ''}
isLoading={false}
onSubmit={handleSubmit}
onDelete={issue?.id && isViewMode ? handleWithdraw : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}