- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
693 lines
24 KiB
TypeScript
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>
|
|
);
|
|
} |