Feature: Zeit-pro-Projekt-Auswertung im Projekt-Detail
time_entry erfasst bezugKind='project'+bezugId, aber niemand sah je, wie viel Zeit ein Projekt frisst. Neue Query time.byProject (Gesamtsumme + pro Person); neue "Getrackte Zeit"-Sektion im Projekt-Detail (blendet sich aus, wenn nichts getrackt wurde). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2eef94afce
commit
7dd1f2fb20
2 files changed
+68
No files matched your search
@@ -420,4 +420,38 @@ export const timeRouter = router({
|
||||
|
||||
return { date, lanes };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Getrackte Zeit auf ein Projekt (time_entry mit bezugKind='project'):
|
||||
* Gesamtsumme + Aufschlüsselung pro Person. bezugId wurde bislang erfasst,
|
||||
* aber nirgends ausgewertet — das speist die „Getrackte Zeit"-Sektion im
|
||||
* Projekt-Detail.
|
||||
*/
|
||||
byProject: protectedProcedure
|
||||
.input(z.object({ projectId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
userId: timeEntry.userId,
|
||||
seconds: sql<number>`COALESCE(SUM(${timeEntry.durationSeconds}), 0)`,
|
||||
})
|
||||
.from(timeEntry)
|
||||
.where(and(eq(timeEntry.bezugKind, 'project'), eq(timeEntry.bezugId, input.projectId)))
|
||||
.groupBy(timeEntry.userId);
|
||||
|
||||
const userRows = await ctx.db.select({ id: user.id, name: user.name }).from(user);
|
||||
const nameById = new Map(userRows.map((u) => [u.id, u.name]));
|
||||
|
||||
const perUser = rows
|
||||
.map((r) => ({
|
||||
userId: r.userId,
|
||||
name: nameById.get(r.userId) ?? '—',
|
||||
seconds: Number(r.seconds),
|
||||
}))
|
||||
.filter((r) => r.seconds > 0)
|
||||
.sort((a, b) => b.seconds - a.seconds);
|
||||
const totalSeconds = perUser.reduce((sum, r) => sum + r.seconds, 0);
|
||||
|
||||
return { totalSeconds, perUser };
|
||||
}),
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { Modal } from '../components/ui/Modal';
|
||||
import { useConfirm } from '../components/ui/ConfirmProvider';
|
||||
import { useCurrentUser } from '../hooks/useCurrentUser';
|
||||
import { voice } from '../lib/voice';
|
||||
import { formatDurationCompact } from '../lib/format-duration';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
|
||||
@@ -191,6 +192,8 @@ export function ProjektRoute() {
|
||||
|
||||
<MilestonesSection projectId={p.id} milestones={milestones} deadline={p.deadline} />
|
||||
|
||||
<ZeitSection projectId={p.id} />
|
||||
|
||||
{p.dodText && (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -584,6 +587,37 @@ function Zieldreieck({ flex }: { flex: ZieldreieckFlex | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Getrackte Zeit ---------------------------------------------------------
|
||||
function ZeitSection({ projectId }: { projectId: string }) {
|
||||
const q = trpc.time.byProject.useQuery({ projectId });
|
||||
if (!q.data || q.data.totalSeconds === 0) return null;
|
||||
return (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Getrackte Zeit</h2>
|
||||
</Card.Header>
|
||||
<Card.Body className="space-y-2">
|
||||
<p className="text-sm">
|
||||
Gesamt:{' '}
|
||||
<span className="font-mono font-semibold">
|
||||
{formatDurationCompact(q.data.totalSeconds)}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{q.data.perUser.map((u) => (
|
||||
<li key={u.userId} className="flex justify-between gap-2">
|
||||
<span className="truncate">{u.name}</span>
|
||||
<span className="shrink-0 font-mono text-[var(--color-ink-muted)]">
|
||||
{formatDurationCompact(u.seconds)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phasen-Stepper + Gate-Review -------------------------------------------
|
||||
function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectPhase }) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
Reference in new issue
Block a user