feat: redesign detail page with stage modal and milestone APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
241
frontend/src/components/detail/StageModal.tsx
Normal file
241
frontend/src/components/detail/StageModal.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Milestone, MilestoneLink } from '../../types';
|
||||
|
||||
export interface StageFormData {
|
||||
title: string;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
description: string;
|
||||
feedback: string;
|
||||
links: MilestoneLink[];
|
||||
}
|
||||
|
||||
interface StageModalProps {
|
||||
mode: 'add' | 'edit';
|
||||
milestone?: Milestone;
|
||||
onSave: (data: StageFormData, files: File[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
function toDateInput(iso: string | null | undefined) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseLinks(raw: string | null | undefined): MilestoneLink[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MilestoneLink[];
|
||||
return Array.isArray(parsed) ? parsed.filter((l) => l.url) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function StageModal({ mode, milestone, onSave, onClose, saving }: StageModalProps) {
|
||||
const [form, setForm] = useState<StageFormData>({
|
||||
title: milestone?.title ?? '',
|
||||
startDate: toDateInput(milestone?.startDate),
|
||||
dueDate: toDateInput(milestone?.dueDate),
|
||||
description: milestone?.description ?? '',
|
||||
feedback: '',
|
||||
links: parseLinks(milestone?.links),
|
||||
});
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [linkLabel, setLinkLabel] = useState('');
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
|
||||
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const addLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
if (!url) return;
|
||||
set('links', [...form.links, { label: linkLabel.trim() || url, url }]);
|
||||
setLinkLabel('');
|
||||
setLinkUrl('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim()) return;
|
||||
await onSave(form, pendingFiles);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<form
|
||||
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="shrink-0 border-b border-slate-100 px-6 py-4">
|
||||
<h2 className="text-xl font-black text-slate-800">
|
||||
{mode === 'add' ? '업무 단계 추가' : '업무 단계 수정'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto px-6 py-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">제목 *</span>
|
||||
<input
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-lg font-semibold focus:border-emerald-400 focus:outline-none"
|
||||
placeholder="단계 제목"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">시작일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => set('startDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">종료일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.dueDate}
|
||||
onChange={(e) => set('dueDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">업무내용</span>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
|
||||
placeholder="단계별 업무 내용 (줄바꿈 가능)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">
|
||||
피드백 {mode === 'edit' && <span className="font-normal text-slate-400">(추가 입력 시 새 항목 등록)</span>}
|
||||
</span>
|
||||
<textarea
|
||||
value={form.feedback}
|
||||
onChange={(e) => set('feedback', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
|
||||
placeholder="피드백 내용"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">업무 성과물</span>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,.ppt,.pptx,.pdf,.doc,.docx,.csv,.png,.jpg,.jpeg,.webp"
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? [...e.target.files] : [];
|
||||
if (list.length) setPendingFiles((prev) => [...prev, ...list]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="w-full text-sm text-slate-600"
|
||||
/>
|
||||
{pendingFiles.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{pendingFiles.map((f, i) => (
|
||||
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span className="truncate">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">엑셀, PPT, PDF, 이미지 등</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-bold text-slate-500">웹 링크</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={linkLabel}
|
||||
onChange={(e) => setLinkLabel(e.target.value)}
|
||||
placeholder="표시명"
|
||||
className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addLink}
|
||||
className="shrink-0 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
{form.links.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{form.links.map((l, i) => (
|
||||
<li key={l.url + i} className="flex items-center justify-between text-sm">
|
||||
<a href={l.url} target="_blank" rel="noreferrer" className="truncate text-blue-600 hover:underline">
|
||||
{l.label}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('links', form.links.filter((_, idx) => idx !== i))}
|
||||
className="text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-slate-100 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="rounded-lg px-4 py-2 text-sm font-bold text-slate-500 hover:bg-slate-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.title.trim()}
|
||||
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '저장 중…' : mode === 'add' ? '추가' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMilestoneLinks(raw: string | null | undefined): MilestoneLink[] {
|
||||
return parseLinks(raw);
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
/**
|
||||
* 레이아웃 목업 — API/DB 연동 없음
|
||||
* 확인: /detail-mock
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface Feedback {
|
||||
author: string;
|
||||
text: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
text: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
title: string;
|
||||
range: string;
|
||||
progress: number;
|
||||
note: string;
|
||||
updatedAt: string;
|
||||
contents: ContentItem[];
|
||||
feedbacks: Feedback[];
|
||||
previewHint: string;
|
||||
timeline: { left: number; width: number; label: string };
|
||||
}
|
||||
|
||||
const MOCK = {
|
||||
title: '가족사 인원현황 대시보드 기획',
|
||||
assignee: '정성호',
|
||||
period: '2026-06-01 ~ 2026-06-30',
|
||||
status: '진행중',
|
||||
section: '인사관리',
|
||||
overview:
|
||||
'가족사 전체 인원 현황을 한눈에 파악할 수 있는 대시보드를 기획·구축합니다. 부서·직급·연령·근속 등 핵심 지표를 시각화합니다.',
|
||||
timeline: { start: '6.01', end: '6.30', today: '6.05', todayLeft: 13 },
|
||||
stages: [
|
||||
{
|
||||
id: '1',
|
||||
title: '대시보드 구성 기획',
|
||||
range: '06/01 ~ 06/16',
|
||||
progress: 80,
|
||||
note: '와이어프레임 1차 확정',
|
||||
updatedAt: '06/05',
|
||||
contents: [
|
||||
{ text: '화면 흐름도·메뉴 구조 확정', date: '06/05' },
|
||||
{ text: '와이어프레임 1차 작성 및 팀 리뷰', date: '06/04' },
|
||||
{ text: '대시보드 KPI 영역·차트 배치 설계', date: '06/01' },
|
||||
],
|
||||
feedbacks: [
|
||||
{ author: '정성호', text: '와이어프레임 v2 공유 완료', date: '06/05' },
|
||||
{ author: '김기획', text: 'KPI 카드 4개로 축소 제안 — 승인', date: '06/03' },
|
||||
],
|
||||
previewHint: '와이어프레임 / 기획서 PDF',
|
||||
timeline: { left: 0, width: 53, label: '① 기획' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '인사 데이터 수집·연동',
|
||||
range: '06/08 ~ 06/12',
|
||||
progress: 45,
|
||||
note: 'API 연동 일정 협의 중',
|
||||
updatedAt: '06/08',
|
||||
contents: [
|
||||
{ text: '매핑표 1차본 HR팀 송부', date: '06/08' },
|
||||
{ text: '개인정보 마스킹 정책 검토', date: '06/07' },
|
||||
{ text: 'HR 원본 데이터 컬럼 매핑표 작성', date: '06/06' },
|
||||
],
|
||||
feedbacks: [
|
||||
{ author: '정성호', text: '매핑표 1차본 HR팀 송부', date: '06/08' },
|
||||
{ author: '이개발', text: '업로드 API 스펙 초안 전달 예정', date: '06/07' },
|
||||
],
|
||||
previewHint: '데이터 매핑표 · 샘플 CSV',
|
||||
timeline: { left: 23, width: 17, label: '② 데이터' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'UI·시각화 데이터 구성',
|
||||
range: '06/22 ~ 06/26',
|
||||
progress: 0,
|
||||
note: '',
|
||||
updatedAt: '06/22',
|
||||
contents: [
|
||||
{ text: 'Wide 사이니지용 레이아웃 분기', date: '06/22' },
|
||||
{ text: '부서·직급·연령대 시각화 구현', date: '06/21' },
|
||||
{ text: '차트 컴포넌트 라이브러리 선정', date: '06/20' },
|
||||
],
|
||||
feedbacks: [],
|
||||
previewHint: 'UI 목업 · 차트 스크린샷',
|
||||
timeline: { left: 70, width: 13, label: '③ UI' },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '데이터 검증·오류 수정',
|
||||
range: '06/26 ~ 06/30',
|
||||
progress: 0,
|
||||
note: '',
|
||||
updatedAt: '06/26',
|
||||
contents: [
|
||||
{ text: '최종 QA 및 인수', date: '06/26' },
|
||||
{ text: '오류 케이스 목록화 및 수정', date: '06/25' },
|
||||
{ text: '실데이터 vs 대시보드 수치 대조', date: '06/24' },
|
||||
],
|
||||
feedbacks: [
|
||||
{ author: '김기획', text: '검증 체크리스트 템플릿 공유 예정', date: '06/20' },
|
||||
],
|
||||
previewHint: 'QA 체크리스트 · 검증 리포트',
|
||||
timeline: { left: 83, width: 17, label: '④ 검증' },
|
||||
},
|
||||
] satisfies Stage[],
|
||||
};
|
||||
|
||||
function dateKey(md: string): number {
|
||||
const [m, d] = md.split('/').map(Number);
|
||||
return m * 100 + d;
|
||||
}
|
||||
|
||||
function sortByDateDesc<T>(items: T[], pick: (item: T) => string): T[] {
|
||||
return [...items].sort((a, b) => dateKey(pick(b)) - dateKey(pick(a)));
|
||||
}
|
||||
|
||||
function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string }) {
|
||||
return (
|
||||
<div className="mb-2 flex shrink-0 items-baseline justify-between gap-2 border-b border-slate-200 pb-2">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">{children}</h3>
|
||||
{sub && <span className="truncate text-sm font-bold text-emerald-600">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PREVIEW_GRADIENTS = [
|
||||
'from-slate-800 via-slate-700 to-emerald-900/50',
|
||||
'from-slate-800 via-blue-900/40 to-slate-700',
|
||||
'from-slate-800 via-violet-900/30 to-slate-700',
|
||||
'from-slate-800 via-amber-900/25 to-slate-700',
|
||||
];
|
||||
|
||||
/** 좌측 4구역 — 스크롤 없이 고정 비율 */
|
||||
function LeftSection({
|
||||
ratio,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
ratio: '1' | '2';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`flex min-h-0 flex-col overflow-hidden border-b border-slate-200 bg-white px-4 py-3 last:border-b-0 ${className}`}
|
||||
style={{ flex: ratio === '1' ? '1 1 0' : '2 2 0' }}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DetailMockPage() {
|
||||
const sortedStages = useMemo(
|
||||
() => sortByDateDesc(MOCK.stages, (s) => s.updatedAt),
|
||||
[],
|
||||
);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(sortedStages[0].id);
|
||||
const selected = sortedStages.find((s) => s.id === selectedId) ?? sortedStages[0];
|
||||
const stageIndex = sortedStages.findIndex((s) => s.id === selectedId);
|
||||
|
||||
const sortedContents = useMemo(
|
||||
() => sortByDateDesc(selected.contents, (c) => c.date),
|
||||
[selected],
|
||||
);
|
||||
const sortedFeedbacks = useMemo(
|
||||
() => sortByDateDesc(selected.feedbacks, (f) => f.date),
|
||||
[selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
|
||||
{/* 상단 바 */}
|
||||
<header className="relative flex h-12 shrink-0 items-center overflow-hidden bg-[linear-gradient(180deg,#37a184_0%,#29724f_20%,#07412e_100%)] px-5 text-white shadow-[0_2px_10px_rgba(0,0,0,0.20)]">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[45%] bg-white/10" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-emerald-200/50" />
|
||||
|
||||
<h1 className="relative z-10 min-w-0 truncate text-[20px] font-bold leading-normal text-[#bad8ca]">
|
||||
{MOCK.title}
|
||||
</h1>
|
||||
|
||||
<div className="relative z-10 ml-auto flex shrink-0 items-center gap-4 text-sm">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-semibold text-white/55">담당</span>{' '}
|
||||
<span className="font-bold text-white/90">{MOCK.assignee}</span>
|
||||
</span>
|
||||
<span className="h-4 w-px bg-white/25" />
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-semibold text-white/55">수행기간</span>{' '}
|
||||
<span className="font-bold text-white/90">{MOCK.period}</span>
|
||||
</span>
|
||||
<span className="h-4 w-px bg-white/25" />
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-semibold text-white/55">부서</span>{' '}
|
||||
<span className="font-bold text-white/90">{MOCK.section}</span>
|
||||
</span>
|
||||
<Badge className="border border-white/20 bg-white/10 text-white/90">{MOCK.status}</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 본문 — 좌 1/4 · 우 3/4 풀사이즈 */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[1fr_3fr]">
|
||||
{/* 좌: 1/5 · 2/5 · 2/5 · 1/5 (스크롤 없음) */}
|
||||
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-300">
|
||||
<LeftSection ratio="1">
|
||||
<PanelLabel>개요</PanelLabel>
|
||||
<p className="min-h-0 flex-1 overflow-hidden text-xl leading-snug text-slate-600 line-clamp-4">
|
||||
{MOCK.overview}
|
||||
</p>
|
||||
</LeftSection>
|
||||
|
||||
<LeftSection ratio="2">
|
||||
<PanelLabel sub={`${sortedStages.length}개`}>업무 내역 (단계)</PanelLabel>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{sortedStages.map((stage) => {
|
||||
const isSelected = stage.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={stage.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(stage.id)}
|
||||
className={`shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-300'
|
||||
: '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">{stage.range}</span>
|
||||
<span
|
||||
className={`text-lg font-black ${
|
||||
stage.progress >= 70
|
||||
? 'text-emerald-600'
|
||||
: stage.progress > 0
|
||||
? 'text-blue-500'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{stage.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: `${stage.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
|
||||
{stage.title}
|
||||
</p>
|
||||
{stage.note && (
|
||||
<p className="mt-0.5 truncate text-sm text-slate-500">{stage.note}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LeftSection>
|
||||
|
||||
<LeftSection ratio="2">
|
||||
<PanelLabel sub={selected.title}>업무내용</PanelLabel>
|
||||
<ul key={selected.id} className="min-h-0 flex-1 space-y-2 overflow-hidden">
|
||||
{sortedContents.map((item) => (
|
||||
<li key={item.text + item.date} className="flex gap-2">
|
||||
<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">{item.date}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</LeftSection>
|
||||
|
||||
<LeftSection ratio="1">
|
||||
<PanelLabel sub={selected.title}>피드백</PanelLabel>
|
||||
<div key={`fb-${selected.id}`} className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{sortedFeedbacks.length === 0 ? (
|
||||
<p className="text-xl text-slate-400">등록된 피드백이 없습니다.</p>
|
||||
) : (
|
||||
<div className="mb-2 min-h-0 flex-1 space-y-2 overflow-hidden">
|
||||
{sortedFeedbacks.map((f) => (
|
||||
<div key={f.date + f.author} className="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div className="mb-0.5 flex justify-between text-sm font-semibold text-slate-400">
|
||||
<span>{f.author}</span>
|
||||
<span>{f.date}</span>
|
||||
</div>
|
||||
<p className="truncate text-2xl font-black leading-snug text-slate-700">{f.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
readOnly
|
||||
placeholder="피드백 입력…"
|
||||
className="mt-auto w-full shrink-0 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-lg placeholder:text-slate-400"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</LeftSection>
|
||||
</aside>
|
||||
|
||||
{/* 우: 프리뷰 + 타임라인 (풀사이즈) */}
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<main className="flex min-h-0 flex-1 flex-col 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">
|
||||
결과물 프리뷰
|
||||
<span className="ml-2 text-base font-normal text-emerald-400/80">{selected.previewHint}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-sm text-white/50">
|
||||
<span className="rounded bg-white/10 px-2 py-1">⊞</span>
|
||||
<span className="rounded bg-white/10 px-2 py-1">75%</span>
|
||||
<span className="rounded bg-white/10 px-2 py-1">⛶</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center">
|
||||
<div
|
||||
key={selected.id}
|
||||
className="flex h-full w-full items-center justify-center p-4 animate-[fadeIn_0.25s_ease-out]"
|
||||
>
|
||||
<div className={`h-full max-h-full w-full max-w-6xl bg-gradient-to-br ${PREVIEW_GRADIENTS[stageIndex % PREVIEW_GRADIENTS.length]}`}>
|
||||
<div className="flex h-full flex-col items-center justify-center p-8">
|
||||
<p className="text-2xl font-black text-white/90">{selected.title}</p>
|
||||
<p className="mt-2 text-lg text-white/40">{selected.previewHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="absolute right-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
|
||||
1 / 3 · 드래그 이동 / Ctrl+휠 확대
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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">
|
||||
<span className="text-lg font-black text-slate-700">업무별 타임라인</span>
|
||||
<span className="text-base font-bold text-emerald-600">{selected.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex justify-between text-sm font-semibold text-slate-400">
|
||||
<span>{MOCK.timeline.start}</span>
|
||||
<span>{MOCK.timeline.end}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-10 bg-slate-100">
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 flex h-full flex-col items-center"
|
||||
style={{ left: `${MOCK.timeline.todayLeft}%` }}
|
||||
>
|
||||
<span className="mb-0.5 bg-emerald-500 px-1.5 py-0.5 text-[10px] font-black text-white">
|
||||
TODAY ({MOCK.timeline.today})
|
||||
</span>
|
||||
<div className="h-full w-0.5 bg-emerald-500" />
|
||||
</div>
|
||||
|
||||
{sortedStages.map((stage) => {
|
||||
const isSelected = stage.id === selectedId;
|
||||
const bar = stage.timeline;
|
||||
return (
|
||||
<button
|
||||
key={stage.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(stage.id)}
|
||||
className={`absolute top-1/2 h-5 -translate-y-1/2 overflow-hidden transition-all ${
|
||||
isSelected ? 'z-20 ring-2 ring-emerald-400' : 'z-0 opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||||
title={stage.title}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-300" />
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-emerald-500"
|
||||
style={{ width: `${stage.progress}%` }}
|
||||
/>
|
||||
<span className="relative flex h-full items-center justify-center truncate px-1 text-[10px] font-bold text-white">
|
||||
{bar.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DetailPage from './pages/DetailPage';
|
||||
import DetailMockPage from './pages/DetailMockPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
@@ -15,9 +14,6 @@ export function AppRouter() {
|
||||
<Route path="/detail" element={<DetailPage />} />
|
||||
<Route path="/detail/:taskId" element={<DetailPage />} />
|
||||
|
||||
{/* 레이아웃 목업 (데이터 연동 없음) */}
|
||||
<Route path="/detail-mock" element={<DetailMockPage />} />
|
||||
|
||||
{/* 관리자 전용 */}
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
|
||||
|
||||
@@ -45,12 +45,18 @@ export interface Task {
|
||||
export interface TaskDetail {
|
||||
id: string;
|
||||
taskId: string;
|
||||
milestoneId: string | null;
|
||||
content: string;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MilestoneLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface KpiMetric {
|
||||
id: string;
|
||||
taskId: string;
|
||||
@@ -63,6 +69,7 @@ export interface KpiMetric {
|
||||
export interface FileRecord {
|
||||
id: string;
|
||||
taskId: string;
|
||||
milestoneId: string | null;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimetype: string;
|
||||
@@ -77,7 +84,9 @@ export interface Milestone {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
links: string | null;
|
||||
completedAt: string | null;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user