feat: implement Project Management with Gantt Chart, Milestones, and CRM enhancements
This commit is contained in:
214
apps/web/src/payload/views/GanttChart/GanttChart.css
Normal file
214
apps/web/src/payload/views/GanttChart/GanttChart.css
Normal 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;
|
||||
}
|
||||
255
apps/web/src/payload/views/GanttChart/index.tsx
Normal file
255
apps/web/src/payload/views/GanttChart/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user