feat: detail page attachments, preview, and file metadata
Add file displayName/sortOrder APIs, result preview with Excel/video support, unified attachment/link editing, feedback modal, and AuthProvider fix. Run prisma migrate deploy on Render builds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import { onDualMonitorEvent } from '../lib/dualMonitor';
|
||||
import { ContextMenu } from '../components/common/ContextMenu';
|
||||
import { StageModal, parseMilestoneLinks, type StageFormData } from '../components/detail/StageModal';
|
||||
import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
|
||||
import { ResultPreview } from '../components/detail/ResultPreview';
|
||||
import {
|
||||
StageModal,
|
||||
parseMilestoneLinks,
|
||||
type StageFileSavePayload,
|
||||
type StageFormData,
|
||||
} from '../components/detail/StageModal';
|
||||
import { sortFilesByOrder } from '../lib/fileDisplay';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string }> = {
|
||||
@@ -191,11 +200,14 @@ function DetailHeader({ task }: { task: Task }) {
|
||||
|
||||
function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
const qc = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const [stageSaving, setStageSaving] = useState(false);
|
||||
const [feedbackSaving, setFeedbackSaving] = useState(false);
|
||||
const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null);
|
||||
const [feedbackModal, setFeedbackModal] = useState<{ mode: 'add' | 'edit'; detail?: TaskDetail } | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
|
||||
const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null);
|
||||
const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null);
|
||||
|
||||
const milestones = task.milestones ?? [];
|
||||
const files = task.files ?? [];
|
||||
@@ -218,17 +230,9 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
|
||||
const stageContents = useMemo(() => {
|
||||
if (!selected?.description) return [];
|
||||
return parseContentLines(selected.description).map((text) => ({
|
||||
text,
|
||||
date: selected.updatedAt,
|
||||
}));
|
||||
return parseContentLines(selected.description);
|
||||
}, [selected]);
|
||||
|
||||
const sortedContents = useMemo(
|
||||
() => sortByIsoDesc(stageContents, (c) => c.date),
|
||||
[stageContents],
|
||||
);
|
||||
|
||||
const stageDetails = useMemo(
|
||||
() => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []),
|
||||
[details, selectedId],
|
||||
@@ -240,10 +244,10 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
);
|
||||
|
||||
const stageFiles = useMemo(
|
||||
() => sortByIsoDesc(
|
||||
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
|
||||
(f) => f.createdAt,
|
||||
),
|
||||
() =>
|
||||
sortFilesByOrder(
|
||||
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
|
||||
),
|
||||
[files, selectedId],
|
||||
);
|
||||
|
||||
@@ -252,18 +256,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
[selected],
|
||||
);
|
||||
|
||||
const [previewFileId, setPreviewFileId] = useState<string | null>(null);
|
||||
const [previewLinkIndex, setPreviewLinkIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFileId(stageFiles[0]?.id ?? null);
|
||||
setPreviewLinkIndex(0);
|
||||
}, [selectedId, stageFiles]);
|
||||
|
||||
const previewFile = stageFiles.find((f) => f.id === previewFileId) ?? stageFiles[0] ?? null;
|
||||
const previewFileIndex = previewFile ? stageFiles.findIndex((f) => f.id === previewFile.id) : -1;
|
||||
const previewLink = !previewFile && stageLinks.length > 0 ? stageLinks[previewLinkIndex] : null;
|
||||
|
||||
const timeline = useMemo(() => buildTimeline(task, milestones), [task, milestones]);
|
||||
|
||||
const deleteMs = useMutation({
|
||||
@@ -271,18 +263,22 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
|
||||
});
|
||||
|
||||
const uploadFiles = async (milestoneId: string, fileList: File[]) => {
|
||||
for (const file of fileList) {
|
||||
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
|
||||
for (const item of filePayload) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('file', item.file);
|
||||
form.append('milestoneId', milestoneId);
|
||||
form.append('sortOrder', String(item.sortOrder));
|
||||
if (item.displayName.trim()) {
|
||||
form.append('displayName', item.displayName.trim());
|
||||
}
|
||||
await apiClient.post(`/files/upload/${task.id}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageSave = async (data: StageFormData, fileList: File[]) => {
|
||||
const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
|
||||
setStageSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -291,7 +287,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
startDate: data.startDate || undefined,
|
||||
dueDate: data.dueDate || undefined,
|
||||
progress: data.progress,
|
||||
feedback: data.feedback.trim() || undefined,
|
||||
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined,
|
||||
};
|
||||
|
||||
@@ -311,12 +306,37 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileList.length > 0) {
|
||||
try {
|
||||
await uploadFiles(milestoneId, fileList);
|
||||
} catch {
|
||||
alert('단계는 저장됐지만 파일 업로드에 실패했습니다.');
|
||||
try {
|
||||
for (const id of filePayload.deletedFileIds) {
|
||||
await apiClient.delete(`/files/${id}`);
|
||||
}
|
||||
for (const rep of filePayload.replacements) {
|
||||
const form = new FormData();
|
||||
form.append('file', rep.file);
|
||||
await apiClient.post(`/files/${rep.id}/replace`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
for (const edit of filePayload.existingEdits) {
|
||||
const original = files.find((f) => f.id === edit.id);
|
||||
if (!original) continue;
|
||||
const prevName = (original.displayName ?? '').trim();
|
||||
const nextName = edit.displayName.trim();
|
||||
const prevOrder = original.sortOrder ?? 0;
|
||||
if (nextName !== prevName || edit.sortOrder !== prevOrder) {
|
||||
await apiClient.patch(`/files/${edit.id}`, {
|
||||
displayName: nextName || null,
|
||||
sortOrder: edit.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (filePayload.uploads.length > 0) {
|
||||
await uploadFiles(milestoneId, filePayload.uploads);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const ax = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const msg = ax.response?.data?.message || ax.message || '파일 처리에 실패했습니다.';
|
||||
alert(`단계는 저장됐지만 ${msg}`);
|
||||
}
|
||||
|
||||
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
||||
@@ -330,19 +350,41 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !selectedId) return;
|
||||
setUploading(true);
|
||||
const handleFeedbackSave = async (data: FeedbackFormData) => {
|
||||
if (!selectedId && feedbackModal?.mode === 'add') {
|
||||
alert('피드백을 추가할 업무 단계를 먼저 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackSaving(true);
|
||||
try {
|
||||
await uploadFiles(selectedId, [file]);
|
||||
if (feedbackModal?.mode === 'add') {
|
||||
await apiClient.post(`/details/${task.id}`, {
|
||||
content: data.content.trim(),
|
||||
authorName: data.authorName.trim(),
|
||||
milestoneId: selectedId,
|
||||
});
|
||||
} else if (feedbackModal?.detail) {
|
||||
await apiClient.patch(`/details/item/${feedbackModal.detail.id}`, {
|
||||
content: data.content.trim(),
|
||||
});
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
||||
setFeedbackModal(null);
|
||||
} catch (err: unknown) {
|
||||
const ax = err as { response?: { data?: { message?: string } }; message?: string };
|
||||
const msg = ax.response?.data?.message || ax.message || '피드백 저장에 실패했습니다.';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setFeedbackSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFeedback = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/details/item/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
|
||||
});
|
||||
|
||||
const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.';
|
||||
|
||||
return (
|
||||
@@ -393,22 +435,21 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
: 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</span>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
|
||||
{stage.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-lg font-black ${
|
||||
className={`shrink-0 text-lg font-black ${
|
||||
progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-500' : 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-1.5 h-1.5 overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
|
||||
{stage.title}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -416,18 +457,33 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
</LeftSection>
|
||||
|
||||
<LeftSection>
|
||||
<PanelLabel sub={selected?.title ?? '전체'}>업무내용</PanelLabel>
|
||||
<ul className="min-h-0 flex-1 space-y-2 overflow-hidden">
|
||||
{sortedContents.length === 0 ? (
|
||||
<li className="text-lg text-slate-400">내용 없음</li>
|
||||
<PanelLabel>업무내용</PanelLabel>
|
||||
<ul
|
||||
className="min-h-0 flex-1 space-y-2 overflow-hidden"
|
||||
onContextMenu={(e) => {
|
||||
if (!selected) return;
|
||||
e.preventDefault();
|
||||
setContentCtx({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
{stageContents.length === 0 ? (
|
||||
<li className="text-lg text-slate-400">
|
||||
{selected ? '우클릭으로 업무내용을 수정하세요.' : '단계를 선택하세요.'}
|
||||
</li>
|
||||
) : (
|
||||
sortedContents.map((item) => (
|
||||
<li key={item.text + item.date} className="flex gap-2">
|
||||
stageContents.map((text) => (
|
||||
<li
|
||||
key={text}
|
||||
className="flex gap-2"
|
||||
onContextMenu={(e) => {
|
||||
if (!selected) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContentCtx({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 text-lg text-blue-400">•</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-2xl font-black leading-snug text-slate-800">{item.text}</p>
|
||||
<p className="text-sm font-semibold text-slate-400">{fmtShort(item.date)}</p>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-800">{text}</p>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
@@ -435,16 +491,44 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
</LeftSection>
|
||||
|
||||
<LeftSection>
|
||||
<PanelLabel sub={selected?.title ?? '전체'}>피드백</PanelLabel>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">피드백</h3>
|
||||
<button
|
||||
type="button"
|
||||
title="피드백 추가"
|
||||
disabled={!selectedId}
|
||||
onClick={() => setFeedbackModal({ mode: 'add' })}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-lg font-bold leading-none text-white hover:bg-emerald-600 disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
onContextMenu={(e) => {
|
||||
if (!selectedId) return;
|
||||
e.preventDefault();
|
||||
setFeedbackCtx({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
{sortedFeedbacks.length === 0 ? (
|
||||
<p className="text-lg text-slate-400">등록된 피드백이 없습니다.</p>
|
||||
<p className="text-lg text-slate-400">우클릭 또는 + 버튼으로 피드백을 추가하세요.</p>
|
||||
) : (
|
||||
<div className="mb-2 min-h-0 flex-1 space-y-2 overflow-hidden">
|
||||
{sortedFeedbacks.map((f) => (
|
||||
<div key={f.id} className="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<p className="mb-0.5 text-sm font-semibold text-slate-400">{fmtShort(f.createdAt)}</p>
|
||||
<p className="truncate text-2xl font-black leading-snug text-slate-700">{f.content}</p>
|
||||
<div
|
||||
key={f.id}
|
||||
className="rounded-lg bg-slate-50 px-3 py-2"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFeedbackCtx({ x: e.clientX, y: e.clientY, detailId: f.id });
|
||||
}}
|
||||
>
|
||||
<p className="truncate text-2xl font-black leading-snug text-slate-700">
|
||||
{f.content}
|
||||
<span className="font-bold text-slate-400"> — {f.author?.name ?? '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -455,89 +539,11 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
|
||||
{/* 우 3/4 */}
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#0f1419]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-2">
|
||||
<span className="text-lg font-bold text-white/75">
|
||||
결과물 프리뷰
|
||||
{previewFile && (
|
||||
<span className="ml-2 text-base font-normal text-emerald-400/80">{previewFile.originalName}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleQuickUpload} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || !selectedId}
|
||||
className="rounded bg-white/10 px-3 py-1 text-sm font-bold text-white/70 hover:bg-white/20 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? '업로드 중…' : '+ 파일'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center">
|
||||
{previewFile ? (
|
||||
<>
|
||||
{previewFile.mimetype.includes('image') ? (
|
||||
<img
|
||||
src={`/api/files/${previewFile.id}/view`}
|
||||
alt={previewFile.originalName}
|
||||
className="max-h-full max-w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`/api/files/${previewFile.id}/view`}
|
||||
title={previewFile.originalName}
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
)}
|
||||
{stageFiles.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prev = previewFileIndex > 0 ? previewFileIndex - 1 : stageFiles.length - 1;
|
||||
setPreviewFileId(stageFiles[prev].id);
|
||||
}}
|
||||
className="absolute left-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = previewFileIndex < stageFiles.length - 1 ? previewFileIndex + 1 : 0;
|
||||
setPreviewFileId(stageFiles[next].id);
|
||||
}}
|
||||
className="absolute right-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : previewLink ? (
|
||||
<iframe
|
||||
src={previewLink.url}
|
||||
title={previewLink.label}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xl text-white/40">
|
||||
{selectedId ? '첨부된 결과물이 없습니다' : '단계를 선택하세요'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(previewFile || previewLink) && (
|
||||
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
|
||||
{previewFile
|
||||
? `${previewFileIndex + 1} / ${stageFiles.length}`
|
||||
: `${previewLinkIndex + 1} / ${stageLinks.length} 링크`}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<ResultPreview
|
||||
files={stageFiles}
|
||||
links={stageLinks}
|
||||
hasSelectedStage={!!selectedId}
|
||||
/>
|
||||
|
||||
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
@@ -597,12 +603,28 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
<StageModal
|
||||
mode={stageModal.mode}
|
||||
milestone={stageModal.milestone}
|
||||
existingFiles={
|
||||
stageModal.milestone
|
||||
? sortFilesByOrder(files.filter((f) => f.milestoneId === stageModal.milestone!.id))
|
||||
: []
|
||||
}
|
||||
saving={stageSaving}
|
||||
onClose={() => setStageModal(null)}
|
||||
onSave={handleStageSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feedbackModal && (
|
||||
<FeedbackModal
|
||||
mode={feedbackModal.mode}
|
||||
detail={feedbackModal.detail}
|
||||
defaultAuthorName={user?.name ?? ''}
|
||||
saving={feedbackSaving}
|
||||
onClose={() => setFeedbackModal(null)}
|
||||
onSave={handleFeedbackSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
@@ -631,6 +653,59 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contentCtx && selected && (
|
||||
<ContextMenu
|
||||
x={contentCtx.x}
|
||||
y={contentCtx.y}
|
||||
onClose={() => setContentCtx(null)}
|
||||
items={[
|
||||
{
|
||||
label: '수정',
|
||||
icon: '✏️',
|
||||
onClick: () => setStageModal({ mode: 'edit', milestone: selected }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feedbackCtx && (
|
||||
<ContextMenu
|
||||
x={feedbackCtx.x}
|
||||
y={feedbackCtx.y}
|
||||
onClose={() => setFeedbackCtx(null)}
|
||||
items={
|
||||
feedbackCtx.detailId
|
||||
? [
|
||||
{
|
||||
label: '피드백 수정',
|
||||
icon: '✏️',
|
||||
onClick: () => {
|
||||
const d = details.find((item) => item.id === feedbackCtx.detailId);
|
||||
if (d) setFeedbackModal({ mode: 'edit', detail: d });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '피드백 삭제',
|
||||
icon: '🗑',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
if (window.confirm('이 피드백을 삭제하시겠습니까?')) {
|
||||
deleteFeedback.mutate(feedbackCtx.detailId!);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: '피드백 추가',
|
||||
icon: '➕',
|
||||
onClick: () => setFeedbackModal({ mode: 'add' }),
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user