Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
468
frontend/src/pages/DetailPage.tsx
Normal file
468
frontend/src/pages/DetailPage.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import { onDualMonitorEvent } from '../lib/dualMonitor';
|
||||
import type { Task, Milestone, FileRecord } from '../types';
|
||||
|
||||
/* ─── 공통 유틸 ───────────────────────────────── */
|
||||
const TAG_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
|
||||
Growth: { bg: '#EFF6FF', text: '#1D4ED8', border: '#BFDBFE' },
|
||||
Policy: { bg: '#F5F3FF', text: '#6D28D9', border: '#DDD6FE' },
|
||||
Performance: { bg: '#ECFDF5', text: '#065F46', border: '#A7F3D0' },
|
||||
Culture: { bg: '#FFFBEB', text: '#92400E', border: '#FDE68A' },
|
||||
Asset: { bg: '#ECFEFF', text: '#155E75', border: '#A5F3FC' },
|
||||
Space: { bg: '#EEF2FF', text: '#3730A3', border: '#C7D2FE' },
|
||||
Safety: { bg: '#FEF2F2', text: '#991B1B', border: '#FECACA' },
|
||||
Environment: { bg: '#F7FEE7', text: '#3F6212', border: '#D9F99D' },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
|
||||
IN_PROGRESS: { bg: '#3B82F6', text: '#fff', label: '진행 중' },
|
||||
REVIEW: { bg: '#F97316', text: '#fff', label: '보류' },
|
||||
TODO: { bg: '#E5E7EB', text: '#374151', label: '대기' },
|
||||
DONE: { bg: '#10B981', text: '#fff', label: '완료' },
|
||||
CANCELLED: { bg: '#D1D5DB', text: '#6B7280', label: '취소' },
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null | undefined) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fileIcon(mime: string) {
|
||||
if (mime.includes('pdf')) return '📄';
|
||||
if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
|
||||
if (mime.includes('word')) return '📝';
|
||||
if (mime.includes('image')) return '🖼';
|
||||
if (mime.includes('video')) return '🎬';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
function fileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<span className="flex-1 h-px bg-gray-100" />
|
||||
{children}
|
||||
<span className="flex-1 h-px bg-gray-100" />
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
대기 화면
|
||||
═══════════════════════════════════════════════ */
|
||||
function WaitingScreen() {
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-center gap-6 bg-slate-50">
|
||||
<div className="w-20 h-20 rounded-full bg-blue-100 flex items-center justify-center text-4xl animate-pulse">←</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-black text-gray-700">카드를 선택하세요</p>
|
||||
<p className="text-base font-medium text-gray-400 mt-2">
|
||||
대시보드에서 업무 카드를 클릭하면<br />이곳에 상세 내용이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
메인 상세 뷰 (탭 없이 한 페이지)
|
||||
═══════════════════════════════════════════════ */
|
||||
function DetailView({ task, files }: { task: Task; files: FileRecord[] }) {
|
||||
const qc = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [previewFile, setPreviewFile] = useState<FileRecord | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [addingMs, setAddingMs] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDate, setNewDate] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const tag = TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' };
|
||||
const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO;
|
||||
const progress = task.progress ?? 0;
|
||||
const progressColor = progress >= 70 ? '#10B981' : progress >= 40 ? '#3B82F6' : '#F97316';
|
||||
const bullets = task.description?.split('\n').filter(Boolean) ?? [];
|
||||
|
||||
// 기간 타임라인
|
||||
const start = task.startDate ? new Date(task.startDate) : null;
|
||||
const end = task.dueDate ? new Date(task.dueDate) : null;
|
||||
const now = new Date();
|
||||
const totalDays = start && end ? Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) : null;
|
||||
const elapsedDays = start ? Math.max(0, Math.ceil((now.getTime() - start.getTime()) / 86400000)) : null;
|
||||
const timePercent = totalDays && elapsedDays !== null
|
||||
? Math.min(100, Math.round((elapsedDays / totalDays) * 100))
|
||||
: null;
|
||||
|
||||
// 마일스톤
|
||||
const { data: milestones = [] } = useQuery<Milestone[]>({
|
||||
queryKey: ['milestones', task.id],
|
||||
queryFn: async () => (await apiClient.get(`/milestones/${task.id}`)).data,
|
||||
});
|
||||
|
||||
const addMs = useMutation({
|
||||
mutationFn: (body: object) => apiClient.post(`/milestones/${task.id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['milestones', task.id] });
|
||||
setAddingMs(false); setNewTitle(''); setNewDate(''); setNewDesc('');
|
||||
},
|
||||
});
|
||||
const toggleMs = useMutation({
|
||||
mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
|
||||
apiClient.patch(`/milestones/item/${id}`, { completed }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
|
||||
});
|
||||
const deleteMs = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
|
||||
});
|
||||
|
||||
const deleteFile = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/files/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['task'] }),
|
||||
});
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('uploadedBy', 'system');
|
||||
try {
|
||||
await apiClient.post(`/files/upload/${task.id}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['task'] });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const msDone = milestones.filter((m) => m.completedAt).length;
|
||||
const msTotal = milestones.length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* ── 헤더 배너 ─────────────────────────────── */}
|
||||
<div className="px-8 pt-7 pb-6"
|
||||
style={{ background: 'linear-gradient(135deg, #1e3260 0%, #1e4fa0 60%, #2a6dd0 100%)' }}>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{task.tag && (
|
||||
<span className="text-sm font-black px-3 py-1 rounded-full border"
|
||||
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
|
||||
{task.tag}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
||||
style={{ background: status.bg, color: status.text }}>
|
||||
{status.label}
|
||||
</span>
|
||||
{task.taskType && (
|
||||
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/15 text-white/80">{task.taskType}</span>
|
||||
)}
|
||||
{task.section && (
|
||||
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/10 text-blue-200 ml-auto">{task.section}</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white leading-snug mb-3">{task.title}</h1>
|
||||
{(task.startDate || task.dueDate) && (
|
||||
<p className="text-blue-200 text-base font-medium">
|
||||
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-8">
|
||||
|
||||
{/* ── 진행률 ───────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-gray-500">진행률</span>
|
||||
<span className="text-2xl font-black" style={{ color: progressColor }}>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%`, background: progressColor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 기간 타임라인 ────────────────────────── */}
|
||||
{start && end && timePercent !== null && (
|
||||
<div className="bg-white border border-gray-100 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex justify-between text-sm font-semibold text-gray-400 mb-3">
|
||||
<span>{fmtDate(task.startDate)}</span>
|
||||
<span className="font-black text-gray-600">총 {totalDays}일</span>
|
||||
<span>{fmtDate(task.dueDate)}</span>
|
||||
</div>
|
||||
<div className="relative h-5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-5 rounded-full bg-gradient-to-r from-blue-400 to-blue-600 transition-all"
|
||||
style={{ width: `${timePercent}%` }} />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs font-black text-white drop-shadow">
|
||||
{timePercent}% 경과
|
||||
</div>
|
||||
</div>
|
||||
{now > end && (
|
||||
<p className="mt-2 text-xs font-bold text-orange-500 text-center">기한 초과</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 내용 ─────────────────────────────────── */}
|
||||
{bullets.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>내용</SectionTitle>
|
||||
<ul className="space-y-2">
|
||||
{bullets.map((b, i) => (
|
||||
<li key={i} className="flex gap-3 bg-white rounded-xl px-5 py-3 shadow-sm border border-gray-100">
|
||||
<span className="shrink-0 text-blue-300 mt-0.5">•</span>
|
||||
<span className="text-base text-gray-700">{b.replace(/^[•·\-]\s*/, '')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 이슈 ─────────────────────────────────── */}
|
||||
{task.issueNote && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-5 py-4">
|
||||
<p className="text-xs font-black text-red-400 mb-1">▶ 이슈</p>
|
||||
<p className="text-base font-semibold text-red-700">{task.issueNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 프로세스 단계 ─────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>프로세스 단계</SectionTitle>
|
||||
|
||||
{msTotal > 0 && (
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-2 bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.round((msDone / msTotal) * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-black text-gray-500 shrink-0">{msDone}/{msTotal} 완료</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 mb-3">
|
||||
{milestones.map((m, idx) => (
|
||||
<li key={m.id}
|
||||
className={`flex gap-4 items-start bg-white rounded-2xl px-5 py-4 border shadow-sm transition-all ${
|
||||
m.completedAt ? 'border-emerald-200 bg-emerald-50/40' : 'border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleMs.mutate({ id: m.id, completed: !m.completedAt })}
|
||||
className={`shrink-0 w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-black transition-all ${
|
||||
m.completedAt
|
||||
? 'bg-emerald-500 border-emerald-500 text-white'
|
||||
: 'border-gray-300 text-gray-400 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
{m.completedAt ? '✓' : idx + 1}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-base font-bold ${m.completedAt ? 'line-through text-gray-400' : 'text-gray-800'}`}>
|
||||
{m.title}
|
||||
</span>
|
||||
{m.dueDate && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
m.completedAt ? 'bg-emerald-100 text-emerald-600' :
|
||||
new Date(m.dueDate) < now ? 'bg-red-100 text-red-600' : 'bg-blue-50 text-blue-500'
|
||||
}`}>
|
||||
{fmtDate(m.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.description && <p className="mt-1 text-sm text-gray-500">{m.description}</p>}
|
||||
{m.completedAt && <p className="mt-1 text-xs text-emerald-500 font-semibold">완료: {fmtDate(m.completedAt)}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (window.confirm('삭제하시겠습니까?')) deleteMs.mutate(m.id); }}
|
||||
className="shrink-0 text-gray-300 hover:text-red-400 transition-colors text-xl leading-none"
|
||||
>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{addingMs ? (
|
||||
<div className="bg-white border border-blue-200 rounded-2xl px-5 py-4 space-y-3 shadow-sm">
|
||||
<input autoFocus placeholder="단계 제목 *" value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="w-full text-base font-semibold border-b border-gray-200 pb-2 focus:outline-none focus:border-blue-400" />
|
||||
<input placeholder="설명 (선택)" value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="w-full text-sm text-gray-600 border-b border-gray-100 pb-2 focus:outline-none" />
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-400" />
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={() => setAddingMs(false)} className="text-sm text-gray-400 hover:text-gray-600 px-3 py-1.5">취소</button>
|
||||
<button
|
||||
onClick={() => { if (newTitle.trim()) addMs.mutate({ title: newTitle.trim(), description: newDesc || undefined, dueDate: newDate || undefined }); }}
|
||||
className="text-sm font-bold bg-blue-500 text-white px-4 py-1.5 rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingMs(true)}
|
||||
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
|
||||
+ 단계 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 첨부파일 ─────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>첨부파일</SectionTitle>
|
||||
|
||||
{/* 미리보기 뷰어 */}
|
||||
{previewFile && (
|
||||
<div className="mb-4 rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm font-bold text-gray-700 truncate">{previewFile.originalName}</span>
|
||||
<div className="flex gap-3 shrink-0 ml-3">
|
||||
<a href={`/api/files/${previewFile.id}/download`}
|
||||
className="text-xs font-semibold text-blue-500 hover:underline">다운로드</a>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: '420px' }}>
|
||||
{previewFile.mimetype.includes('image') ? (
|
||||
<img src={`/api/files/${previewFile.id}/view`} alt={previewFile.originalName}
|
||||
className="w-full h-full object-contain bg-gray-50 p-4" />
|
||||
) : (
|
||||
<iframe src={`/api/files/${previewFile.id}/view`} title={previewFile.originalName}
|
||||
className="w-full h-full border-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-3">
|
||||
{files.length === 0 && !uploading && (
|
||||
<p className="text-center text-gray-400 text-sm py-4">첨부된 파일이 없습니다</p>
|
||||
)}
|
||||
{files.map((f) => (
|
||||
<div key={f.id}
|
||||
className={`flex items-center gap-4 bg-white rounded-xl px-4 py-3 border shadow-sm hover:border-blue-200 transition-all cursor-pointer group ${
|
||||
previewFile?.id === f.id ? 'border-blue-300 bg-blue-50/30' : 'border-gray-100'
|
||||
}`}
|
||||
onClick={() => setPreviewFile(previewFile?.id === f.id ? null : f)}
|
||||
>
|
||||
<span className="text-2xl shrink-0">{fileIcon(f.mimetype)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-800 truncate">{f.originalName}</p>
|
||||
<p className="text-xs text-gray-400">{fileSize(f.size)} · {fmtDate(f.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<a href={`/api/files/${f.id}/download`} onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs font-semibold text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100">
|
||||
다운로드
|
||||
</a>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (window.confirm('삭제하시겠습니까?')) deleteFile.mutate(f.id); }}
|
||||
className="text-xs font-semibold text-red-400 hover:text-red-600 px-2 py-1 rounded hover:bg-red-50">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{uploading && (
|
||||
<div className="flex items-center gap-3 bg-blue-50 rounded-xl px-4 py-3 border border-blue-200">
|
||||
<span className="text-sm font-semibold text-blue-600 animate-pulse">업로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
|
||||
<button onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
|
||||
+ 파일 첨부
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-8" /> {/* 하단 여백 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
메인 페이지
|
||||
═══════════════════════════════════════════════ */
|
||||
export default function DetailPage() {
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onDualMonitorEvent((evt) => {
|
||||
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
|
||||
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const { data: task, isLoading } = useQuery({
|
||||
queryKey: ['task', taskId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<Task & { files: FileRecord[] }>(`/tasks/${taskId}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!taskId,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const tag = task ? (TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' }) : null;
|
||||
const status = task ? (STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-50 overflow-hidden" style={{ fontSize: '18px' }}>
|
||||
{/* 최상단 바 */}
|
||||
<div className="shrink-0 flex items-center gap-3 px-6 h-12"
|
||||
style={{ background: 'linear-gradient(90deg, #1e3260 0%, #1e4fa0 100%)' }}>
|
||||
<span className="text-white font-black text-base tracking-wide">EENE 업무 상세</span>
|
||||
{task && tag && status && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-white/20" />
|
||||
<span className="text-white/80 text-sm font-bold truncate max-w-[300px]">{task.title}</span>
|
||||
<div className="ml-auto flex gap-2 shrink-0">
|
||||
{task.tag && (
|
||||
<span className="text-xs font-black px-2 py-0.5 rounded-full border"
|
||||
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
|
||||
{task.tag}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ background: status.bg, color: status.text }}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400 text-xl">불러오는 중...</div>
|
||||
) : !task ? (
|
||||
<WaitingScreen />
|
||||
) : (
|
||||
<DetailView task={task} files={(task as any).files ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user