Anstehende Meilensteine im Dashboard anzeigen
Projektübergreifende Liste offener/überfälliger Meilensteine mit Datum,
sortiert nach Fälligkeit. Überfällige rot markiert, relative Tagesangabe
("in 3 Tagen" / "überfällig"), direkt verlinkt aufs Projekt. Sitzt oben
zwischen Showstoppern und kritischen Modi.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e46d107b2d
commit
1b186d8be6
2 files changed
+90
-4
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { and, eq, ne, isNull, lt, count, desc } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, risk, type Area, type Project } from '@cockpit/db';
|
||||
import { and, eq, ne, isNull, isNotNull, lt, count, asc, desc } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, risk, milestone, type Area, type Project } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, previousWerktag } from '../lib/werktag.js';
|
||||
import { aggregateAreaAmpel } from '../lib/ampel.js';
|
||||
@@ -7,8 +7,16 @@ import { aggregateAreaAmpel } from '../lib/ampel.js';
|
||||
export const dashboardRouter = router({
|
||||
overview: protectedProcedure.query(async ({ ctx }) => {
|
||||
const todayDate = todayIso();
|
||||
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows, showstopperRows] =
|
||||
await Promise.all([
|
||||
const [
|
||||
areas,
|
||||
allProjects,
|
||||
openBlockers,
|
||||
todayPresence,
|
||||
totalActiveUsers,
|
||||
feedRows,
|
||||
showstopperRows,
|
||||
milestoneRows,
|
||||
] = await Promise.all([
|
||||
ctx.db.select().from(area).orderBy(area.order),
|
||||
ctx.db.select().from(project).where(eq(project.archiviert, false)),
|
||||
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
|
||||
@@ -52,6 +60,28 @@ export const dashboardRouter = router({
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
),
|
||||
// Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend.
|
||||
ctx.db
|
||||
.select({
|
||||
id: milestone.id,
|
||||
title: milestone.title,
|
||||
due: milestone.due,
|
||||
status: milestone.status,
|
||||
projectId: milestone.projectId,
|
||||
projectName: project.name,
|
||||
areaId: project.areaId,
|
||||
})
|
||||
.from(milestone)
|
||||
.innerJoin(project, eq(project.id, milestone.projectId))
|
||||
.where(
|
||||
and(
|
||||
ne(milestone.status, 'done'),
|
||||
isNotNull(milestone.due),
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(milestone.due))
|
||||
.limit(15),
|
||||
]);
|
||||
|
||||
const projectsByArea = new Map<string, Project[]>();
|
||||
@@ -98,6 +128,10 @@ export const dashboardRouter = router({
|
||||
},
|
||||
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
|
||||
showstoppers: showstopperRows,
|
||||
meilensteine: milestoneRows.map((m) => ({
|
||||
...m,
|
||||
ueberfaellig: m.due != null && m.due < todayDate,
|
||||
})),
|
||||
kritischeModi: allProjects
|
||||
.filter((p) => p.modus === 'eskalation' || p.modus === 'taskforce')
|
||||
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId, modus: p.modus })),
|
||||
|
||||
@@ -104,6 +104,45 @@ export function DashboardRoute() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{q.data.meilensteine.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">Anstehende Meilensteine</h2>
|
||||
<Card.Root>
|
||||
<Card.Body className="p-0">
|
||||
<ul className="divide-y divide-[var(--color-border)]">
|
||||
{q.data.meilensteine.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{m.title}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={clsx(
|
||||
m.ueberfaellig
|
||||
? 'font-medium text-[var(--color-ampel-red-fg)]'
|
||||
: 'text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{formatDate(m.due!)}
|
||||
{m.ueberfaellig && ' · überfällig'}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-faint)]">·</span>
|
||||
<span className="text-[var(--color-ink-muted)]">{relativeDays(m.due!, q.data.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/projekt/${m.projectId}`}
|
||||
className="shrink-0 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
{m.projectName} →
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{q.data.kritischeModi.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">Projekte in Eskalation / Taskforce</h2>
|
||||
@@ -364,5 +403,18 @@ function formatDate(input: string | Date): string {
|
||||
}).format(dt);
|
||||
}
|
||||
|
||||
// Relative Tagesangabe für Meilensteine ("heute", "in 3 Tagen", "vor 2 Tagen").
|
||||
function relativeDays(dueIso: string, todayIso: string): string {
|
||||
const due = new Date(`${dueIso}T00:00:00`);
|
||||
const today = new Date(`${todayIso}T00:00:00`);
|
||||
if (Number.isNaN(due.getTime()) || Number.isNaN(today.getTime())) return '';
|
||||
const diff = Math.round((due.getTime() - today.getTime()) / 86_400_000);
|
||||
if (diff === 0) return 'heute';
|
||||
if (diff === 1) return 'morgen';
|
||||
if (diff === -1) return 'gestern';
|
||||
if (diff > 0) return `in ${diff} Tagen`;
|
||||
return `vor ${Math.abs(diff)} Tagen`;
|
||||
}
|
||||
|
||||
// Button als referenz behalten (Linter)
|
||||
void Button;
|
||||
Reference in new issue
Block a user