feat: redesign detail page with stage modal and milestone APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "startDate" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "links" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "milestoneId" TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS "files_milestoneId_idx" ON "files"("milestoneId");
|
||||||
|
ALTER TABLE "files" ADD CONSTRAINT "files_milestoneId_fkey"
|
||||||
|
FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "task_details" ADD COLUMN IF NOT EXISTS "milestoneId" TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS "task_details_milestoneId_idx" ON "task_details"("milestoneId");
|
||||||
|
ALTER TABLE "task_details" ADD CONSTRAINT "task_details_milestoneId_fkey"
|
||||||
|
FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -97,15 +97,18 @@ enum Priority {
|
|||||||
model TaskDetail {
|
model TaskDetail {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
|
milestoneId String?
|
||||||
content String
|
content String
|
||||||
updatedBy String
|
updatedBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
milestone Milestone? @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
|
||||||
author User @relation(fields: [updatedBy], references: [id])
|
author User @relation(fields: [updatedBy], references: [id])
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
|
@@index([milestoneId])
|
||||||
@@map("task_details")
|
@@map("task_details")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +135,7 @@ model KpiMetric {
|
|||||||
model File {
|
model File {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
|
milestoneId String?
|
||||||
filename String // 저장된 파일명 (UUID)
|
filename String // 저장된 파일명 (UUID)
|
||||||
originalName String // 원본 파일명
|
originalName String // 원본 파일명
|
||||||
mimetype String
|
mimetype String
|
||||||
@@ -141,9 +145,11 @@ model File {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
milestone Milestone? @relation(fields: [milestoneId], references: [id], onDelete: SetNull)
|
||||||
uploader User @relation(fields: [uploadedBy], references: [id])
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
|
@@index([milestoneId])
|
||||||
@@map("files")
|
@@map("files")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +160,17 @@ model Milestone {
|
|||||||
taskId String
|
taskId String
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
|
links String? // JSON: [{ "label": string, "url": string }]
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
details TaskDetail[]
|
||||||
|
files File[]
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
@@map("milestones")
|
@@map("milestones")
|
||||||
|
|||||||
@@ -16,15 +16,26 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
|||||||
const task = await prisma.task.findUnique({ where: { id: taskId } });
|
const task = await prisma.task.findUnique({ where: { id: taskId } });
|
||||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
const body = req.body as Record<string, string>;
|
||||||
|
const milestoneId = body.milestoneId?.trim() || null;
|
||||||
|
|
||||||
|
if (milestoneId) {
|
||||||
|
const milestone = await prisma.milestone.findFirst({
|
||||||
|
where: { id: milestoneId, taskId },
|
||||||
|
});
|
||||||
|
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
const fileRecord = await prisma.file.create({
|
const fileRecord = await prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
|
milestoneId,
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
originalName: req.file.originalname,
|
originalName: req.file.originalname,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: req.file.mimetype,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
uploadedBy: (req.body as Record<string, string>).uploadedBy ?? 'system',
|
uploadedBy: body.uploadedBy ?? 'system',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,29 @@ import { AppError } from '../middleware/errorHandler';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /api/milestones/:taskId — 업무의 마일스톤 목록
|
async function resolveUpdatedBy(taskId: string): Promise<string> {
|
||||||
|
const task = await prisma.task.findUnique({ where: { id: taskId }, select: { creatorId: true } });
|
||||||
|
if (task?.creatorId) return task.creatorId;
|
||||||
|
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } });
|
||||||
|
if (!admin) throw new AppError(500, '피드백 작성자를 찾을 수 없습니다.');
|
||||||
|
return admin.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLinks(links: unknown): string | null {
|
||||||
|
if (!links) return null;
|
||||||
|
if (typeof links === 'string') {
|
||||||
|
try {
|
||||||
|
JSON.parse(links);
|
||||||
|
return links;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(links)) return JSON.stringify(links);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/milestones/:taskId
|
||||||
router.get('/:taskId', async (req, res, next) => {
|
router.get('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const milestones = await prisma.milestone.findMany({
|
const milestones = await prisma.milestone.findMany({
|
||||||
@@ -17,52 +39,90 @@ router.get('/:taskId', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/milestones/:taskId — 마일스톤 추가
|
// POST /api/milestones/:taskId
|
||||||
router.post('/:taskId', async (req, res, next) => {
|
router.post('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, dueDate } = req.body as Record<string, string>;
|
const taskId = req.params.taskId;
|
||||||
|
const { title, description, startDate, dueDate, feedback, links } =
|
||||||
|
req.body as Record<string, string>;
|
||||||
|
|
||||||
const count = await prisma.milestone.count({ where: { taskId: req.params.taskId } });
|
if (!title?.trim()) throw new AppError(400, '단계 제목은 필수입니다.');
|
||||||
|
|
||||||
|
const count = await prisma.milestone.count({ where: { taskId } });
|
||||||
|
|
||||||
const milestone = await prisma.milestone.create({
|
const milestone = await prisma.milestone.create({
|
||||||
data: {
|
data: {
|
||||||
taskId: req.params.taskId,
|
taskId,
|
||||||
title,
|
title: title.trim(),
|
||||||
description: description || null,
|
description: description?.trim() || null,
|
||||||
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
dueDate: dueDate ? new Date(dueDate) : null,
|
dueDate: dueDate ? new Date(dueDate) : null,
|
||||||
|
links: normalizeLinks(links),
|
||||||
order: count,
|
order: count,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (feedback?.trim()) {
|
||||||
|
const updatedBy = await resolveUpdatedBy(taskId);
|
||||||
|
await prisma.taskDetail.create({
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
content: feedback.trim(),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json(milestone);
|
res.status(201).json(milestone);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/milestones/item/:id — 마일스톤 수정 (완료 처리 포함)
|
// PATCH /api/milestones/item/:id
|
||||||
router.patch('/item/:id', async (req, res, next) => {
|
router.patch('/item/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, dueDate, completed, order } = req.body as Record<string, string | boolean | number>;
|
const { title, description, startDate, dueDate, feedback, links, completed, order } =
|
||||||
|
req.body as Record<string, string | boolean | number>;
|
||||||
|
|
||||||
|
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
|
|
||||||
const milestone = await prisma.milestone.update({
|
const milestone = await prisma.milestone.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...(title !== undefined && { title: title as string }),
|
...(title !== undefined && { title: String(title).trim() }),
|
||||||
...(description !== undefined && { description: description as string || null }),
|
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
||||||
|
...(startDate !== undefined && { startDate: startDate ? new Date(startDate as string) : null }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }),
|
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }),
|
||||||
|
...(links !== undefined && { links: normalizeLinks(links) }),
|
||||||
...(order !== undefined && { order: Number(order) }),
|
...(order !== undefined && { order: Number(order) }),
|
||||||
...(completed !== undefined && {
|
...(completed !== undefined && {
|
||||||
completedAt: completed ? new Date() : null,
|
completedAt: completed ? new Date() : null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof feedback === 'string' && feedback.trim()) {
|
||||||
|
const updatedBy = await resolveUpdatedBy(existing.taskId);
|
||||||
|
await prisma.taskDetail.create({
|
||||||
|
data: {
|
||||||
|
taskId: existing.taskId,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
content: feedback.trim(),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(milestone);
|
res.json(milestone);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/milestones/item/:id — 마일스톤 삭제
|
// DELETE /api/milestones/item/:id
|
||||||
router.delete('/item/:id', async (req, res, next) => {
|
router.delete('/item/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await prisma.milestone.delete({ where: { id: req.params.id } });
|
await prisma.milestone.delete({ where: { id: req.params.id } });
|
||||||
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,148 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../lib/apiClient';
|
import { apiClient } from '../lib/apiClient';
|
||||||
import { onDualMonitorEvent } from '../lib/dualMonitor';
|
import { onDualMonitorEvent } from '../lib/dualMonitor';
|
||||||
import type { Task, Milestone, FileRecord } from '../types';
|
import { ContextMenu } from '../components/common/ContextMenu';
|
||||||
|
import { StageModal, parseMilestoneLinks, type StageFormData } from '../components/detail/StageModal';
|
||||||
|
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
|
||||||
|
|
||||||
/* ─── 공통 유틸 ───────────────────────────────── */
|
const STATUS_CONFIG: Record<string, { label: string }> = {
|
||||||
const TAG_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
|
IN_PROGRESS: { label: '진행중' },
|
||||||
Growth: { bg: '#EFF6FF', text: '#1D4ED8', border: '#BFDBFE' },
|
REVIEW: { label: '보류' },
|
||||||
Policy: { bg: '#F5F3FF', text: '#6D28D9', border: '#DDD6FE' },
|
TODO: { label: '대기' },
|
||||||
Performance: { bg: '#ECFDF5', text: '#065F46', border: '#A7F3D0' },
|
DONE: { label: '완료' },
|
||||||
Culture: { bg: '#FFFBEB', text: '#92400E', border: '#FDE68A' },
|
CANCELLED: { label: '취소' },
|
||||||
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 }> = {
|
type TaskWithRelations = Task & {
|
||||||
IN_PROGRESS: { bg: '#3B82F6', text: '#fff', label: '진행 중' },
|
files: FileRecord[];
|
||||||
REVIEW: { bg: '#F97316', text: '#fff', label: '보류' },
|
details: TaskDetail[];
|
||||||
TODO: { bg: '#E5E7EB', text: '#374151', label: '대기' },
|
milestones: Milestone[];
|
||||||
DONE: { bg: '#10B981', text: '#fff', label: '완료' },
|
|
||||||
CANCELLED: { bg: '#D1D5DB', text: '#6B7280', label: '취소' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined) {
|
function fmtDate(iso: string | null | undefined) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIcon(mime: string) {
|
function fmtShort(iso: string | null | undefined) {
|
||||||
if (mime.includes('pdf')) return '📄';
|
if (!iso) return '';
|
||||||
if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
|
const d = new Date(iso);
|
||||||
if (mime.includes('word')) return '📝';
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
||||||
if (mime.includes('image')) return '🖼';
|
|
||||||
if (mime.includes('video')) return '🎬';
|
|
||||||
return '📎';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileSize(bytes: number) {
|
function fmtStageRange(m: Milestone) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (m.startDate && m.dueDate) return `${fmtShort(m.startDate)} ~ ${fmtShort(m.dueDate)}`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (m.dueDate) return fmtShort(m.dueDate);
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
return fmtShort(m.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
function fmtTimelineLabel(iso: string | null | undefined) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getMonth() + 1}.${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByIsoDesc<T>(items: T[], pick: (item: T) => string) {
|
||||||
|
return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneProgress(m: Milestone) {
|
||||||
|
return m.completedAt ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentLines(text: string | null | undefined) {
|
||||||
|
if (!text) return [];
|
||||||
|
return text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineBar {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
progress: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeline(task: Task, milestones: Milestone[]): {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
today: string;
|
||||||
|
todayLeft: number;
|
||||||
|
bars: TimelineBar[];
|
||||||
|
} | null {
|
||||||
|
if (!task.startDate || !task.dueDate) return null;
|
||||||
|
|
||||||
|
const startMs = new Date(task.startDate).getTime();
|
||||||
|
const endMs = new Date(task.dueDate).getTime();
|
||||||
|
const range = Math.max(endMs - startMs, 86400000);
|
||||||
|
const now = Date.now();
|
||||||
|
const todayLeft = Math.min(100, Math.max(0, ((now - startMs) / range) * 100));
|
||||||
|
const ordered = [...milestones].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const bars: TimelineBar[] = ordered.map((m, i) => {
|
||||||
|
const barStart = m.startDate
|
||||||
|
? new Date(m.startDate).getTime()
|
||||||
|
: i > 0 && ordered[i - 1].dueDate
|
||||||
|
? new Date(ordered[i - 1].dueDate!).getTime()
|
||||||
|
: startMs;
|
||||||
|
const barEnd = m.dueDate ? new Date(m.dueDate).getTime() : endMs;
|
||||||
|
const left = Math.max(0, ((barStart - startMs) / range) * 100);
|
||||||
|
const width = Math.max(4, ((barEnd - barStart) / range) * 100);
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
label: `${i + 1}`,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
progress: milestoneProgress(m),
|
||||||
|
title: m.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
return {
|
||||||
|
start: fmtTimelineLabel(task.startDate),
|
||||||
|
end: fmtTimelineLabel(task.dueDate),
|
||||||
|
today: `${today.getMonth() + 1}.${String(today.getDate()).padStart(2, '0')}`,
|
||||||
|
todayLeft,
|
||||||
|
bars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${className}`}>
|
||||||
<span className="flex-1 h-px bg-gray-100" />
|
|
||||||
{children}
|
{children}
|
||||||
<span className="flex-1 h-px bg-gray-100" />
|
</span>
|
||||||
</h3>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeftSection({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-0 flex-col overflow-hidden border-b border-slate-200 px-4 py-3 last:border-b-0">
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
|
||||||
대기 화면
|
|
||||||
═══════════════════════════════════════════════ */
|
|
||||||
function WaitingScreen() {
|
function WaitingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full items-center justify-center gap-6 bg-slate-50">
|
<div className="flex h-full flex-col items-center justify-center gap-6 bg-[#eef2f5]">
|
||||||
<div className="w-20 h-20 rounded-full bg-blue-100 flex items-center justify-center text-4xl animate-pulse">←</div>
|
<div className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full bg-emerald-100 text-4xl">←</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-2xl font-black text-gray-700">카드를 선택하세요</p>
|
<p className="text-2xl font-black text-slate-700">카드를 선택하세요</p>
|
||||||
<p className="text-base font-medium text-gray-400 mt-2">
|
<p className="mt-2 text-lg font-medium text-slate-400">
|
||||||
대시보드에서 업무 카드를 클릭하면<br />이곳에 상세 내용이 표시됩니다.
|
대시보드에서 업무 카드를 클릭하면<br />이곳에 상세 내용이 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,337 +150,470 @@ function WaitingScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
function DetailHeader({ task }: { task: Task }) {
|
||||||
메인 상세 뷰 (탭 없이 한 페이지)
|
const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO;
|
||||||
═══════════════════════════════════════════════ */
|
const period =
|
||||||
function DetailView({ task, files }: { task: Task; files: FileRecord[] }) {
|
task.startDate || task.dueDate
|
||||||
|
? `${fmtDate(task.startDate)} ~ ${fmtDate(task.dueDate)}`
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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]">
|
||||||
|
{task.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">{task.assignee?.name ?? '—'}</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">{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">{task.section ?? '—'}</span>
|
||||||
|
</span>
|
||||||
|
<Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailView({ task }: { task: TaskWithRelations }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<FileRecord | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [addingMs, setAddingMs] = useState(false);
|
const [stageSaving, setStageSaving] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null);
|
||||||
const [newDate, setNewDate] = useState('');
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
|
||||||
const [newDesc, setNewDesc] = useState('');
|
|
||||||
|
|
||||||
const tag = TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' };
|
const milestones = task.milestones ?? [];
|
||||||
const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO;
|
const files = task.files ?? [];
|
||||||
const progress = task.progress ?? 0;
|
const details = task.details ?? [];
|
||||||
const progressColor = progress >= 70 ? '#10B981' : progress >= 40 ? '#3B82F6' : '#F97316';
|
|
||||||
const bullets = task.description?.split('\n').filter(Boolean) ?? [];
|
|
||||||
|
|
||||||
// 기간 타임라인
|
const sortedStages = useMemo(
|
||||||
const start = task.startDate ? new Date(task.startDate) : null;
|
() => sortByIsoDesc(milestones, (m) => m.updatedAt),
|
||||||
const end = task.dueDate ? new Date(task.dueDate) : null;
|
[milestones],
|
||||||
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 [selectedId, setSelectedId] = useState<string | null>(sortedStages[0]?.id ?? null);
|
||||||
const { data: milestones = [] } = useQuery<Milestone[]>({
|
|
||||||
queryKey: ['milestones', task.id],
|
useEffect(() => {
|
||||||
queryFn: async () => (await apiClient.get(`/milestones/${task.id}`)).data,
|
if (!selectedId || !sortedStages.some((s) => s.id === selectedId)) {
|
||||||
});
|
setSelectedId(sortedStages[0]?.id ?? null);
|
||||||
|
}
|
||||||
|
}, [task.id, sortedStages, selectedId]);
|
||||||
|
|
||||||
|
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
|
||||||
|
|
||||||
|
const stageContents = useMemo(() => {
|
||||||
|
if (!selected?.description) return [];
|
||||||
|
return parseContentLines(selected.description).map((text) => ({
|
||||||
|
text,
|
||||||
|
date: selected.updatedAt,
|
||||||
|
}));
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const sortedContents = useMemo(
|
||||||
|
() => sortByIsoDesc(stageContents, (c) => c.date),
|
||||||
|
[stageContents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageDetails = useMemo(
|
||||||
|
() => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []),
|
||||||
|
[details, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedFeedbacks = useMemo(
|
||||||
|
() => sortByIsoDesc(stageDetails, (d) => d.createdAt),
|
||||||
|
[stageDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageFiles = useMemo(
|
||||||
|
() => sortByIsoDesc(
|
||||||
|
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
|
||||||
|
(f) => f.createdAt,
|
||||||
|
),
|
||||||
|
[files, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageLinks = useMemo(
|
||||||
|
() => (selected ? parseMilestoneLinks(selected.links) : []),
|
||||||
|
[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 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({
|
const deleteMs = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteFile = useMutation({
|
const uploadFiles = async (milestoneId: string, fileList: File[]) => {
|
||||||
mutationFn: (id: string) => apiClient.delete(`/files/${id}`),
|
for (const file of fileList) {
|
||||||
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();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
form.append('uploadedBy', 'system');
|
form.append('milestoneId', milestoneId);
|
||||||
try {
|
form.append('uploadedBy', task.creatorId);
|
||||||
await apiClient.post(`/files/upload/${task.id}`, form, {
|
await apiClient.post(`/files/upload/${task.id}`, form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ['task'] });
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStageSave = async (data: StageFormData, fileList: File[]) => {
|
||||||
|
setStageSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: data.title.trim(),
|
||||||
|
description: data.description.trim() || undefined,
|
||||||
|
startDate: data.startDate || undefined,
|
||||||
|
dueDate: data.dueDate || undefined,
|
||||||
|
feedback: data.feedback.trim() || undefined,
|
||||||
|
links: JSON.stringify(data.links),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stageModal?.mode === 'add') {
|
||||||
|
const { data: created } = await apiClient.post<Milestone>(`/milestones/${task.id}`, payload);
|
||||||
|
if (fileList.length) await uploadFiles(created.id, fileList);
|
||||||
|
setSelectedId(created.id);
|
||||||
|
} else if (stageModal?.milestone) {
|
||||||
|
await apiClient.patch(`/milestones/item/${stageModal.milestone.id}`, payload);
|
||||||
|
if (fileList.length) await uploadFiles(stageModal.milestone.id, fileList);
|
||||||
|
}
|
||||||
|
|
||||||
|
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
||||||
|
setStageModal(null);
|
||||||
|
} finally {
|
||||||
|
setStageSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !selectedId) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await uploadFiles(selectedId, [file]);
|
||||||
|
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const msDone = milestones.filter((m) => m.completedAt).length;
|
const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.';
|
||||||
const msTotal = milestones.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid h-full min-h-0 grid-cols-[1fr_3fr] grid-rows-1">
|
||||||
{/* ── 헤더 배너 ─────────────────────────────── */}
|
{/* 좌 1/4 — 1:2:2:1 세로 비율 */}
|
||||||
<div className="px-8 pt-7 pb-6"
|
<aside className="grid h-full min-h-0 grid-rows-[1fr_2fr_2fr_1fr] overflow-hidden border-r border-slate-300 bg-white">
|
||||||
style={{ background: 'linear-gradient(135deg, #1e3260 0%, #1e4fa0 60%, #2a6dd0 100%)' }}>
|
<LeftSection>
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<PanelLabel>개요</PanelLabel>
|
||||||
{task.tag && (
|
<p className="line-clamp-4 min-h-0 flex-1 overflow-hidden text-xl leading-snug text-slate-600">
|
||||||
<span className="text-sm font-black px-3 py-1 rounded-full border"
|
{overview}
|
||||||
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
|
</p>
|
||||||
{task.tag}
|
{task.issueNote && (
|
||||||
</span>
|
<p className="mt-1 truncate text-sm font-bold text-red-500">▶ {task.issueNote}</p>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
</LeftSection>
|
||||||
style={{ background: status.bg, color: status.text }}>
|
|
||||||
{status.label}
|
<LeftSection>
|
||||||
</span>
|
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
|
||||||
{task.taskType && (
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">업무 단계</h3>
|
||||||
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/15 text-white/80">{task.taskType}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
title="업무 단계 추가"
|
||||||
|
onClick={() => setStageModal({ 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"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
{sortedStages.length === 0 && (
|
||||||
|
<p className="text-lg text-slate-400">+ 버튼으로 단계를 추가하세요.</p>
|
||||||
)}
|
)}
|
||||||
{task.section && (
|
{sortedStages.map((stage) => {
|
||||||
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/10 text-blue-200 ml-auto">{task.section}</span>
|
const isSelected = stage.id === selectedId;
|
||||||
|
const progress = milestoneProgress(stage);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={stage.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(stage.id)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY, stageId: 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">{fmtStageRange(stage)}</span>
|
||||||
|
<span
|
||||||
|
className={`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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
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">{fmtShort(item.date)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</LeftSection>
|
||||||
|
|
||||||
|
<LeftSection>
|
||||||
|
<PanelLabel sub={selected?.title ?? '전체'}>피드백</PanelLabel>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{sortedFeedbacks.length === 0 ? (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-white leading-snug mb-3">{task.title}</h1>
|
</LeftSection>
|
||||||
{(task.startDate || task.dueDate) && (
|
</aside>
|
||||||
<p className="text-blue-200 text-base font-medium">
|
|
||||||
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
|
{/* 우 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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-8">
|
{(previewFile || previewLink) && (
|
||||||
|
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
|
||||||
{/* ── 진행률 ───────────────────────────────── */}
|
{previewFile
|
||||||
<div>
|
? `${previewFileIndex + 1} / ${stageFiles.length}`
|
||||||
<div className="flex items-center justify-between mb-2">
|
: `${previewLinkIndex + 1} / ${stageLinks.length} 링크`}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* ── 내용 ─────────────────────────────────── */}
|
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}>
|
||||||
{bullets.length > 0 && (
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div>
|
<span className="text-lg font-black text-slate-700">업무별 타임라인</span>
|
||||||
<SectionTitle>내용</SectionTitle>
|
<span className="truncate text-base font-bold text-emerald-600">{selected?.title ?? task.title}</span>
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 이슈 ─────────────────────────────────── */}
|
{timeline ? (
|
||||||
{task.issueNote && (
|
<>
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl px-5 py-4">
|
<div className="mb-1.5 flex justify-between text-sm font-semibold text-slate-400">
|
||||||
<p className="text-xs font-black text-red-400 mb-1">▶ 이슈</p>
|
<span>{timeline.start}</span>
|
||||||
<p className="text-base font-semibold text-red-700">{task.issueNote}</p>
|
<span>{timeline.end}</span>
|
||||||
</div>
|
</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"
|
||||||
<div>
|
style={{ left: `${timeline.todayLeft}%` }}
|
||||||
<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
|
<span className="mb-0.5 bg-emerald-500 px-1.5 py-0.5 text-[10px] font-black text-white">
|
||||||
onClick={() => toggleMs.mutate({ id: m.id, completed: !m.completedAt })}
|
TODAY ({timeline.today})
|
||||||
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>
|
</span>
|
||||||
{m.dueDate && (
|
<div className="h-full w-0.5 bg-emerald-500" />
|
||||||
<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>
|
</div>
|
||||||
|
{timeline.bars.map((bar) => {
|
||||||
|
const isSelected = bar.id === selectedId;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (window.confirm('삭제하시겠습니까?')) deleteMs.mutate(m.id); }}
|
key={bar.id}
|
||||||
className="shrink-0 text-gray-300 hover:text-red-400 transition-colors text-xl leading-none"
|
type="button"
|
||||||
>×</button>
|
onClick={() => setSelectedId(bar.id)}
|
||||||
</li>
|
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'
|
||||||
</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)}
|
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||||||
|
title={bar.title}
|
||||||
>
|
>
|
||||||
<span className="text-2xl shrink-0">{fileIcon(f.mimetype)}</span>
|
<div className="absolute inset-0 bg-slate-300" />
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
<p className="text-sm font-bold text-gray-800 truncate">{f.originalName}</p>
|
className="absolute inset-y-0 left-0 bg-emerald-500"
|
||||||
<p className="text-xs text-gray-400">{fileSize(f.size)} · {fmtDate(f.createdAt)}</p>
|
style={{ width: `${bar.progress}%` }}
|
||||||
</div>
|
/>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<span className="relative flex h-full items-center justify-center truncate px-1 text-[10px] font-bold text-white">
|
||||||
<a href={`/api/files/${f.id}/download`} onClick={(e) => e.stopPropagation()}
|
{bar.label}
|
||||||
className="text-xs font-semibold text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100">
|
</span>
|
||||||
다운로드
|
|
||||||
</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>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
))}
|
) : (
|
||||||
{uploading && (
|
<p className="text-sm text-slate-400">수행기간이 설정되면 타임라인이 표시됩니다.</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
|
{stageModal && (
|
||||||
<button onClick={() => fileInputRef.current?.click()}
|
<StageModal
|
||||||
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">
|
mode={stageModal.mode}
|
||||||
+ 파일 첨부
|
milestone={stageModal.milestone}
|
||||||
</button>
|
saving={stageSaving}
|
||||||
</div>
|
onClose={() => setStageModal(null)}
|
||||||
|
onSave={handleStageSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-8" /> {/* 하단 여백 */}
|
{ctxMenu && (
|
||||||
</div>
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: '단계 수정',
|
||||||
|
icon: '✏️',
|
||||||
|
onClick: () => {
|
||||||
|
const ms = milestones.find((m) => m.id === ctxMenu.stageId);
|
||||||
|
if (ms) setStageModal({ mode: 'edit', milestone: ms });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '단계 삭제',
|
||||||
|
icon: '🗑',
|
||||||
|
danger: true,
|
||||||
|
onClick: () => {
|
||||||
|
if (window.confirm('이 단계를 삭제하시겠습니까?')) {
|
||||||
|
deleteMs.mutate(ctxMenu.stageId);
|
||||||
|
if (selectedId === ctxMenu.stageId) setSelectedId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
|
||||||
메인 페이지
|
|
||||||
═══════════════════════════════════════════════ */
|
|
||||||
export default function DetailPage() {
|
export default function DetailPage() {
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -417,50 +628,26 @@ export default function DetailPage() {
|
|||||||
const { data: task, isLoading } = useQuery({
|
const { data: task, isLoading } = useQuery({
|
||||||
queryKey: ['task', taskId],
|
queryKey: ['task', taskId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get<Task & { files: FileRecord[] }>(`/tasks/${taskId}`);
|
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${taskId}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!taskId,
|
enabled: !!taskId,
|
||||||
staleTime: 10_000,
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-slate-50 overflow-hidden" style={{ fontSize: '18px' }}>
|
<div className="flex h-screen flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
|
||||||
{/* 최상단 바 */}
|
{task && <DetailHeader task={task} />}
|
||||||
<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 min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-full items-center justify-center text-gray-400 text-xl">불러오는 중...</div>
|
<div className="flex h-full items-center justify-center text-xl text-slate-400">불러오는 중...</div>
|
||||||
) : !task ? (
|
) : !task ? (
|
||||||
<WaitingScreen />
|
<WaitingScreen />
|
||||||
) : (
|
) : (
|
||||||
<DetailView task={task} files={(task as any).files ?? []} />
|
<div className="h-full min-h-0 flex-1">
|
||||||
|
<DetailView task={task} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import DetailPage from './pages/DetailPage';
|
import DetailPage from './pages/DetailPage';
|
||||||
import DetailMockPage from './pages/DetailMockPage';
|
|
||||||
import AdminPage from './pages/AdminPage';
|
import AdminPage from './pages/AdminPage';
|
||||||
import NotFoundPage from './pages/NotFoundPage';
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
|
|
||||||
@@ -15,9 +14,6 @@ export function AppRouter() {
|
|||||||
<Route path="/detail" element={<DetailPage />} />
|
<Route path="/detail" element={<DetailPage />} />
|
||||||
<Route path="/detail/:taskId" element={<DetailPage />} />
|
<Route path="/detail/:taskId" element={<DetailPage />} />
|
||||||
|
|
||||||
{/* 레이아웃 목업 (데이터 연동 없음) */}
|
|
||||||
<Route path="/detail-mock" element={<DetailMockPage />} />
|
|
||||||
|
|
||||||
{/* 관리자 전용 */}
|
{/* 관리자 전용 */}
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,18 @@ export interface Task {
|
|||||||
export interface TaskDetail {
|
export interface TaskDetail {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
milestoneId: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MilestoneLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KpiMetric {
|
export interface KpiMetric {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -63,6 +69,7 @@ export interface KpiMetric {
|
|||||||
export interface FileRecord {
|
export interface FileRecord {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
milestoneId: string | null;
|
||||||
filename: string;
|
filename: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
mimetype: string;
|
mimetype: string;
|
||||||
@@ -77,7 +84,9 @@ export interface Milestone {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
startDate: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
links: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user