feat: implement Project Management with Gantt Chart, Milestones, and CRM enhancements

This commit is contained in:
2026-03-01 00:26:59 +01:00
parent 4b5609a75e
commit 6444cf1e81
47 changed files with 15312 additions and 7373 deletions

View File

@@ -0,0 +1,214 @@
/* ─── Gantt Widget (embedded in Projects list) ─── */
.gantt-widget {
border: 1px solid var(--theme-elevation-150);
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
background: var(--theme-elevation-50);
}
.gantt-widget__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
user-select: none;
background: var(--theme-elevation-100);
border-bottom: 1px solid var(--theme-elevation-150);
}
.gantt-widget__header:hover {
background: var(--theme-elevation-150);
}
.gantt-widget__title {
font-weight: 600;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 10px;
}
.gantt-widget__count {
font-weight: 400;
font-size: 0.75rem;
color: var(--theme-elevation-500);
background: var(--theme-elevation-200);
padding: 2px 8px;
border-radius: 99px;
}
.gantt-widget__toggle {
font-size: 1rem;
color: var(--theme-elevation-400);
}
.gantt-widget__body {
padding: 16px;
position: relative;
}
.gantt-widget__empty {
margin: 0;
font-size: 0.85rem;
color: var(--theme-elevation-400);
text-align: center;
padding: 1rem 0;
}
/* ─── Timeline header (months) ─── */
.gantt-timeline__header {
position: relative;
height: 24px;
margin-bottom: 8px;
border-bottom: 1px solid var(--theme-elevation-150);
}
.gantt-timeline__month {
position: absolute;
top: 0;
font-size: 0.7rem;
font-weight: 600;
color: var(--theme-elevation-400);
text-transform: uppercase;
transform: translateX(-50%);
white-space: nowrap;
}
/* ─── Today line ─── */
.gantt-timeline__today {
position: absolute;
top: 40px;
bottom: 16px;
width: 2px;
background: var(--theme-error-500);
z-index: 2;
pointer-events: none;
}
.gantt-timeline__today-label {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
font-weight: 700;
color: var(--theme-error-500);
text-transform: uppercase;
white-space: nowrap;
}
/* ─── Rows ─── */
.gantt-timeline__rows {
display: flex;
flex-direction: column;
}
.gantt-row {
display: flex;
align-items: center;
height: 32px;
border-bottom: 1px solid var(--theme-elevation-100);
}
.gantt-row:last-child {
border-bottom: none;
}
.gantt-row--project {
font-weight: 600;
font-size: 0.85rem;
}
.gantt-row--milestone {
font-size: 0.8rem;
color: var(--theme-elevation-600);
}
/* ─── Labels ─── */
.gantt-row__label {
min-width: 180px;
max-width: 220px;
padding-right: 12px;
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.gantt-row__label--indent {
padding-left: 20px;
font-weight: 400;
}
.gantt-row__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.gantt-row__priority {
font-size: 0.6rem;
flex-shrink: 0;
}
.gantt-row__link {
color: inherit;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.gantt-row__link:hover {
text-decoration: underline;
}
/* ─── Bars ─── */
.gantt-row__bar-area {
flex: 1;
position: relative;
height: 100%;
}
.gantt-bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
height: 16px;
min-width: 4px;
}
.gantt-bar--project {
height: 20px;
opacity: 0.85;
}
.gantt-bar--milestone {
height: 12px;
border-radius: 3px;
}
.gantt-bar__label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.6rem;
font-weight: 600;
color: white;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: none;
}

View File

@@ -0,0 +1,255 @@
"use client";
import React, { useEffect, useState } from "react";
import "./GanttChart.css";
type Milestone = {
id: string;
name: string;
status: "todo" | "in_progress" | "done";
priority: "low" | "medium" | "high";
startDate?: string;
targetDate?: string;
};
type Project = {
id: string;
title: string;
status: "draft" | "in_progress" | "review" | "completed";
startDate?: string;
targetDate?: string;
milestones?: Milestone[];
};
const STATUS_COLORS: Record<string, string> = {
draft: "#94a3b8",
in_progress: "#3b82f6",
review: "#f59e0b",
completed: "#22c55e",
todo: "#94a3b8",
done: "#22c55e",
};
const PRIORITY_LABELS: Record<string, string> = {
low: "▽",
medium: "◆",
high: "▲",
};
export const GanttChartView: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const fetchProjects = async () => {
try {
const res = await fetch("/api/projects?limit=100&depth=0");
if (!res.ok) return;
const json = await res.json();
setProjects(json.docs || []);
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
// Calculate timeline bounds
const allDates: number[] = [];
projects.forEach((p) => {
if (p.startDate) allDates.push(new Date(p.startDate).getTime());
if (p.targetDate) allDates.push(new Date(p.targetDate).getTime());
p.milestones?.forEach((m) => {
if (m.startDate) allDates.push(new Date(m.startDate).getTime());
if (m.targetDate) allDates.push(new Date(m.targetDate).getTime());
});
});
const minDate = allDates.length > 0 ? Math.min(...allDates) : Date.now();
const maxDate =
allDates.length > 0 ? Math.max(...allDates) : Date.now() + 86400000 * 90;
const totalSpan = Math.max(maxDate - minDate, 86400000); // at least 1 day
const getBarStyle = (start?: string, end?: string) => {
if (!start && !end) return null;
const s = start ? new Date(start).getTime() : minDate;
const e = end ? new Date(end).getTime() : maxDate;
const left = ((s - minDate) / totalSpan) * 100;
const width = Math.max(((e - s) / totalSpan) * 100, 1);
return { left: `${left}%`, width: `${width}%` };
};
const formatDate = (d?: string) => {
if (!d) return "";
return new Date(d).toLocaleDateString("de-DE", {
day: "2-digit",
month: "short",
});
};
// Generate month markers
const monthMarkers: { label: string; left: number }[] = [];
if (allDates.length > 0) {
const startMonth = new Date(minDate);
startMonth.setDate(1);
const endMonth = new Date(maxDate);
const cursor = new Date(startMonth);
while (cursor <= endMonth) {
const pos = ((cursor.getTime() - minDate) / totalSpan) * 100;
if (pos >= 0 && pos <= 100) {
monthMarkers.push({
label: cursor.toLocaleDateString("de-DE", {
month: "short",
year: "2-digit",
}),
left: pos,
});
}
cursor.setMonth(cursor.getMonth() + 1);
}
}
const hasDates = allDates.length >= 2;
if (loading) return null;
if (projects.length === 0) return null;
return (
<div className="gantt-widget">
<div
className="gantt-widget__header"
onClick={() => setCollapsed(!collapsed)}
>
<span className="gantt-widget__title">
📊 Timeline
<span className="gantt-widget__count">
{projects.length} Projects
</span>
</span>
<span className="gantt-widget__toggle">{collapsed ? "▸" : "▾"}</span>
</div>
{!collapsed && (
<div className="gantt-widget__body">
{!hasDates ? (
<p className="gantt-widget__empty">
Add start and target dates to your projects to see the timeline.
</p>
) : (
<>
{/* Month markers */}
<div className="gantt-timeline__header">
{monthMarkers.map((m, i) => (
<span
key={i}
className="gantt-timeline__month"
style={{ left: `${m.left}%` }}
>
{m.label}
</span>
))}
</div>
{/* Today marker */}
{(() => {
const todayPos = ((Date.now() - minDate) / totalSpan) * 100;
if (todayPos >= 0 && todayPos <= 100) {
return (
<div
className="gantt-timeline__today"
style={{ left: `${todayPos}%` }}
>
<span className="gantt-timeline__today-label">Today</span>
</div>
);
}
return null;
})()}
{/* Project rows */}
<div className="gantt-timeline__rows">
{projects.map((project) => (
<React.Fragment key={project.id}>
<div className="gantt-row gantt-row--project">
<div className="gantt-row__label">
<span
className="gantt-row__dot"
style={{
backgroundColor: STATUS_COLORS[project.status],
}}
/>
<a
href={`/admin/collections/projects/${project.id}`}
className="gantt-row__link"
>
{project.title}
</a>
</div>
<div className="gantt-row__bar-area">
{(() => {
const style = getBarStyle(
project.startDate,
project.targetDate,
);
if (!style) return null;
return (
<div
className="gantt-bar gantt-bar--project"
style={{
...style,
backgroundColor: STATUS_COLORS[project.status],
}}
>
<span className="gantt-bar__label">
{formatDate(project.startDate)} {" "}
{formatDate(project.targetDate)}
</span>
</div>
);
})()}
</div>
</div>
{/* Milestone rows */}
{project.milestones?.map((m, i) => {
const barStyle = getBarStyle(m.startDate, m.targetDate);
return (
<div
key={m.id || i}
className="gantt-row gantt-row--milestone"
>
<div className="gantt-row__label gantt-row__label--indent">
<span
className="gantt-row__priority"
title={m.priority}
>
{PRIORITY_LABELS[m.priority]}
</span>
{m.name}
</div>
<div className="gantt-row__bar-area">
{barStyle && (
<div
className="gantt-bar gantt-bar--milestone"
style={{
...barStyle,
backgroundColor: STATUS_COLORS[m.status],
opacity: m.status === "done" ? 0.5 : 1,
}}
/>
)}
</div>
</div>
);
})}
</React.Fragment>
))}
</div>
</>
)}
</div>
)}
</div>
);
};