Initial commit: everythingbutwork MVP
Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets) und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion), Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0. Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 + Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv). Planungs-Artefakte unter _bmad-output/planning-artifacts/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -0,0 +1,29 @@
|
||||
// Root tRPC-Router. Pro Story kommen weitere Sub-Router dazu.
|
||||
|
||||
import { router, publicProcedure } from '../trpc.js';
|
||||
import { dayEntryRouter } from './day-entry.js';
|
||||
import { userRouter } from './user.js';
|
||||
import { pulsRouter } from './puls.js';
|
||||
import { projectRouter } from './project.js';
|
||||
import { dealRouter, gtmRouter } from './deal.js';
|
||||
import { campaignRouter } from './campaign.js';
|
||||
import { areaRouter } from './area.js';
|
||||
import { blockerRouter } from './blocker.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: publicProcedure.query(() => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
user: userRouter,
|
||||
area: areaRouter,
|
||||
project: projectRouter,
|
||||
deal: dealRouter,
|
||||
gtm: gtmRouter,
|
||||
campaign: campaignRouter,
|
||||
blocker: blockerRouter,
|
||||
dayEntry: dayEntryRouter,
|
||||
puls: pulsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { area } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
export const areaRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(area).orderBy(area.order);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq, isNull, desc } from 'drizzle-orm';
|
||||
import { blocker, project, user } from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
export const blockerRouter = router({
|
||||
listOpen: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db
|
||||
.select({
|
||||
blocker: blocker,
|
||||
opener: user,
|
||||
})
|
||||
.from(blocker)
|
||||
.leftJoin(user, eq(user.id, blocker.openedBy))
|
||||
.where(isNull(blocker.resolvedAt))
|
||||
.orderBy(desc(blocker.openedAt));
|
||||
}),
|
||||
|
||||
byProject: protectedProcedure
|
||||
.input(z.object({ projectId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(blocker)
|
||||
.where(eq(blocker.projectId, input.projectId))
|
||||
.orderBy(desc(blocker.openedAt));
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
areaId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
ownerUserId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [created] = await ctx.db
|
||||
.insert(blocker)
|
||||
.values({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
areaId: input.areaId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
openedBy: ctx.user.id,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
resolve: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
blockerId: z.string().uuid(),
|
||||
resolutionText: z.string().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const b = (
|
||||
await ctx.db.select().from(blocker).where(eq(blocker.id, input.blockerId)).limit(1)
|
||||
)[0];
|
||||
if (!b) throw new TRPCError({ code: 'NOT_FOUND', message: 'Blocker nicht gefunden.' });
|
||||
|
||||
// Authority-Check: bei projektgebundenen Blockern nur P1; sonst Ersteller oder Verantwortlicher.
|
||||
let allowed = false;
|
||||
if (b.projectId) {
|
||||
const p = (
|
||||
await ctx.db.select().from(project).where(eq(project.id, b.projectId)).limit(1)
|
||||
)[0];
|
||||
if (p?.verantwortlicheUserId === ctx.user.id) allowed = true;
|
||||
} else {
|
||||
if (b.openedBy === ctx.user.id || b.ownerUserId === ctx.user.id) allowed = true;
|
||||
}
|
||||
if (!allowed) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message:
|
||||
'Nur die/der Projektverantwortliche darf diesen Blocker auflösen. (Bei projektlosen Blockern: Ersteller oder Verantwortliche/r.)',
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(blocker)
|
||||
.set({
|
||||
resolvedBy: ctx.user.id,
|
||||
resolvedAt: new Date(),
|
||||
resolutionText: input.resolutionText,
|
||||
})
|
||||
.where(eq(blocker.id, input.blockerId))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { campaign, leadHandover, deal, area } from '@cockpit/db';
|
||||
import { CampaignSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const MARKETING_AREA_SLUG = 'marketing';
|
||||
|
||||
export const campaignRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(campaign).orderBy(desc(campaign.createdAt));
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CampaignSchemas.createCampaignInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const a = (
|
||||
await ctx.db.select().from(area).where(eq(area.slug, MARKETING_AREA_SLUG)).limit(1)
|
||||
)[0];
|
||||
if (!a) throw new Error('Marketing-Area fehlt — bitte seed laufen lassen.');
|
||||
const [created] = await ctx.db
|
||||
.insert(campaign)
|
||||
.values({
|
||||
areaId: a.id,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
reach: input.reach,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
funnel: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Lead-Funnel: simple Aggregation der bekannten Quellen.
|
||||
const campaigns = await ctx.db.select().from(campaign);
|
||||
const reichweite = campaigns.reduce((acc, c) => acc + c.reach, 0);
|
||||
const handovers = await ctx.db.select().from(leadHandover);
|
||||
const leads = handovers.length;
|
||||
const deals = await ctx.db.select().from(deal);
|
||||
const mql = deals.filter((d) => ['erstgespraech', 'demo', 'angebot'].includes(d.stage)).length;
|
||||
const uebergabe = handovers.length;
|
||||
// Frontend kann die Stage-Übergänge daraus rendern.
|
||||
return {
|
||||
reichweite,
|
||||
website: Math.round(reichweite * 0.2), // Placeholder bis ein Tracking-Feed existiert.
|
||||
leads,
|
||||
mql,
|
||||
uebergabe,
|
||||
};
|
||||
}),
|
||||
|
||||
crosslink: protectedProcedure.query(async ({ ctx }) => {
|
||||
const handovers = await ctx.db.select().from(leadHandover);
|
||||
const dealsFromHandovers = handovers.filter((h) => h.dealId !== null).length;
|
||||
// Won-Volumen aus Handover-bezogenen Deals dieses Quartals.
|
||||
const dealRows = await ctx.db.select().from(deal);
|
||||
const wonValue = dealRows
|
||||
.filter((d) => d.wonAt)
|
||||
.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
|
||||
return {
|
||||
uebergebenSeitTagen: 30,
|
||||
leadsUebergeben: handovers.length,
|
||||
dealsErzeugt: dealsFromHandovers,
|
||||
gewonneneEurQuartal: wonValue,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, isNull, isNotNull, lt, asc, inArray } from 'drizzle-orm';
|
||||
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso } from '../lib/werktag.js';
|
||||
|
||||
export const dayEntryRouter = router({
|
||||
/**
|
||||
* Heutige ToDos aller aktiven Mitarbeiter, gruppiert pro User.
|
||||
* Pro PRD-Änderung 2026-05-25: Heutige ToDos sind teamweit sichtbar
|
||||
* (ersetzt die ältere private-vor-Publikation-Regel).
|
||||
*/
|
||||
todayTeam: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, date));
|
||||
if (entries.length === 0) return [] as Array<{ userId: string; entryStatus: string; todos: Todo[] }>;
|
||||
const ids = entries.map((e) => e.id);
|
||||
const todos = await ctx.db
|
||||
.select()
|
||||
.from(todo)
|
||||
.where(inArray(todo.dayEntryId, ids))
|
||||
.orderBy(asc(todo.order));
|
||||
const todosByEntry = new Map<string, Todo[]>();
|
||||
for (const t of todos) {
|
||||
const arr = todosByEntry.get(t.dayEntryId) ?? [];
|
||||
arr.push(t);
|
||||
todosByEntry.set(t.dayEntryId, arr);
|
||||
}
|
||||
return entries.map((e) => ({
|
||||
userId: e.userId,
|
||||
entryStatus: e.status,
|
||||
todos: todosByEntry.get(e.id) ?? [],
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Offene ToDos der vergangenen Tage, die noch nicht übertragen wurden.
|
||||
* Werden auf der Heute-Seite vor dem Einstempeln eingeblendet (siehe FR-22 Erweiterung).
|
||||
*/
|
||||
pendingCarryOver: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db
|
||||
.select({
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
bezugKind: todo.bezugKind,
|
||||
bezugId: todo.bezugId,
|
||||
fromDate: todo.date,
|
||||
})
|
||||
.from(todo)
|
||||
.where(
|
||||
and(
|
||||
eq(todo.userId, ctx.user.id),
|
||||
eq(todo.status, 'open'),
|
||||
eq(todo.carriedOver, false),
|
||||
lt(todo.date, todayIso()),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(todo.date), asc(todo.order))
|
||||
.limit(20);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Heute-Status der eigenen Person.
|
||||
*/
|
||||
today: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
|
||||
.limit(1);
|
||||
const entry = rows[0] ?? null;
|
||||
if (!entry) {
|
||||
return { date, entry: null, todos: [] as Todo[] };
|
||||
}
|
||||
const todos = await ctx.db
|
||||
.select()
|
||||
.from(todo)
|
||||
.where(eq(todo.dayEntryId, entry.id))
|
||||
.orderBy(todo.order);
|
||||
return { date, entry, todos };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Morgen-Einstempeln + ToDo-Liste setzen.
|
||||
*/
|
||||
checkIn: protectedProcedure
|
||||
.input(DayEntrySchemas.checkInInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const date = input.date ?? todayIso();
|
||||
|
||||
// Verhindert Doppel-Einstempeln. Wenn bereits Einträge existieren, Fehler.
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Du bist heute schon locked in.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(dayEntry)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
date,
|
||||
status: 'begun',
|
||||
checkedInAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('DayEntry konnte nicht angelegt werden.');
|
||||
|
||||
const todoRows = await tx
|
||||
.insert(todo)
|
||||
.values(
|
||||
input.todos.map((t, idx) => ({
|
||||
dayEntryId: created.id,
|
||||
userId: ctx.user.id,
|
||||
date,
|
||||
title: t.title,
|
||||
order: idx,
|
||||
bezugKind: t.bezugKind ?? null,
|
||||
bezugId: t.bezugId ?? null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Alle alten offenen ToDos der Person als "übertragen" markieren.
|
||||
// Egal, ob sie in der heutigen Liste landeten oder nicht — der User hat
|
||||
// beim Einstempeln über sie entschieden.
|
||||
const stale = await tx
|
||||
.select({ id: todo.id })
|
||||
.from(todo)
|
||||
.where(
|
||||
and(
|
||||
eq(todo.userId, ctx.user.id),
|
||||
eq(todo.status, 'open'),
|
||||
eq(todo.carriedOver, false),
|
||||
lt(todo.date, date),
|
||||
),
|
||||
);
|
||||
if (stale.length > 0) {
|
||||
await tx
|
||||
.update(todo)
|
||||
.set({ carriedOver: true })
|
||||
.where(
|
||||
inArray(
|
||||
todo.id,
|
||||
stale.map((s) => s.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { entry: created, todos: todoRows, carriedOver: stale.length };
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* ToDos im Verlauf des Tages ändern (vor Reflexion).
|
||||
*/
|
||||
updateTodos: protectedProcedure
|
||||
.input(DayEntrySchemas.todoMutationInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
|
||||
.limit(1);
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
|
||||
}
|
||||
if (entry.status === 'full') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Du hast schon reflektiert. ToDos sind eingefroren.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
await tx.delete(todo).where(eq(todo.dayEntryId, entry.id));
|
||||
if (input.todos.length === 0) return { todos: [] as Todo[] };
|
||||
const newTodos = await tx
|
||||
.insert(todo)
|
||||
.values(
|
||||
input.todos.map((t, idx) => ({
|
||||
dayEntryId: entry.id,
|
||||
userId: ctx.user.id,
|
||||
date: entry.date,
|
||||
title: t.title,
|
||||
order: idx,
|
||||
bezugKind: t.bezugKind ?? null,
|
||||
bezugId: t.bezugId ?? null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
return { todos: newTodos };
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
|
||||
*/
|
||||
submitReflection: protectedProcedure
|
||||
.input(DayEntrySchemas.submitReflectionInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(eq(dayEntry.id, input.dayEntryId), eq(dayEntry.userId, ctx.user.id)))
|
||||
.limit(1);
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
|
||||
}
|
||||
if (entry.status === 'full' && entry.editLockedAt && entry.editLockedAt < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Die Reflexion ist nicht mehr editierbar.',
|
||||
});
|
||||
}
|
||||
|
||||
// Pflicht-Trend bei Bezug=Projekt (Story 2.4)
|
||||
const todoRows = await ctx.db.select().from(todo).where(eq(todo.dayEntryId, entry.id));
|
||||
const hasProjectBezug = todoRows.some((t) => t.bezugKind === 'project');
|
||||
if (hasProjectBezug && !input.trend) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Bei Projektbezug ist der Trend Pflicht.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
// ToDo-Statuswechsel.
|
||||
for (const t of input.todos) {
|
||||
await tx
|
||||
.update(todo)
|
||||
.set({
|
||||
status: t.status,
|
||||
dropReason: t.dropReason ?? null,
|
||||
completedAt: t.status === 'done' ? new Date() : null,
|
||||
droppedAt: t.status === 'dropped' ? new Date() : null,
|
||||
})
|
||||
.where(and(eq(todo.id, t.id), eq(todo.dayEntryId, entry.id)));
|
||||
}
|
||||
|
||||
let blockerRecord: { id: string } | null = null;
|
||||
if (input.blocker) {
|
||||
const [b] = await tx
|
||||
.insert(blocker)
|
||||
.values({
|
||||
title: input.blocker.title,
|
||||
description: input.blocker.description ?? null,
|
||||
openedBy: ctx.user.id,
|
||||
})
|
||||
.returning({ id: blocker.id });
|
||||
blockerRecord = b ?? null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await tx
|
||||
.update(dayEntry)
|
||||
.set({
|
||||
status: 'full',
|
||||
reflectedAt: now,
|
||||
reflectionText: input.reflectionText,
|
||||
ampel: input.ampel,
|
||||
trend: input.trend ?? null,
|
||||
url: input.url ?? null,
|
||||
blockerId: blockerRecord?.id ?? entry.blockerId,
|
||||
editLockedAt: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(dayEntry.id, entry.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('Update fehlgeschlagen.');
|
||||
return { entry: updated, blocker: blockerRecord };
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Konsolidierter Vortrags-Feed: alle bereits publizierten Einträge.
|
||||
* Nutzbar in Story 3.4 (Puls-Feed).
|
||||
*/
|
||||
feed: protectedProcedure.query(async ({ ctx }) => {
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(isNotNull(dayEntry.publishedAt))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.limit(50);
|
||||
return entries;
|
||||
}),
|
||||
|
||||
/**
|
||||
* „Heute fehlt"-Liste (Anwesenheits-Übersicht).
|
||||
*/
|
||||
todayPresence: protectedProcedure.query(async ({ ctx }) => {
|
||||
const date = todayIso();
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
userId: dayEntry.userId,
|
||||
status: dayEntry.status,
|
||||
})
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, date));
|
||||
return rows;
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper für Cron-Endpoint
|
||||
export async function publishYesterdaysEntries(
|
||||
ctx: { db: import('@cockpit/db').Db },
|
||||
yesterdayIso: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const result = await ctx.db
|
||||
.update(dayEntry)
|
||||
.set({ publishedAt: now })
|
||||
.where(and(eq(dayEntry.date, yesterdayIso), isNull(dayEntry.publishedAt)));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { desc, eq, sql, sum } from 'drizzle-orm';
|
||||
import { deal, gtmStep, area } from '@cockpit/db';
|
||||
import { DealSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const SALES_AREA_SLUG = 'vertrieb';
|
||||
|
||||
async function getSalesAreaId(db: import('@cockpit/db').Db): Promise<string> {
|
||||
const a = (await db.select().from(area).where(eq(area.slug, SALES_AREA_SLUG)).limit(1))[0];
|
||||
if (!a) throw new Error('Sales-Area fehlt — bitte seed laufen lassen.');
|
||||
return a.id;
|
||||
}
|
||||
|
||||
export const dealRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.select().from(deal).orderBy(desc(deal.enteredStageAt));
|
||||
}),
|
||||
|
||||
kpis: protectedProcedure.query(async ({ ctx }) => {
|
||||
const allDeals = await ctx.db.select().from(deal);
|
||||
const open = allDeals.filter((d) => !d.wonAt);
|
||||
const won = allDeals.filter((d) => d.wonAt);
|
||||
|
||||
const weightedPipeline = open.reduce((acc, d) => acc + (d.valueEur * d.probability) / 100, 0);
|
||||
|
||||
// „Laufendes Quartal" = aktuelles Kalenderquartal (Server-Zeit).
|
||||
const now = new Date();
|
||||
const qStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
|
||||
const wonThisQuarter = won.filter((d) => d.wonAt && d.wonAt >= qStart);
|
||||
const wonValueQuarter = wonThisQuarter.reduce((acc, d) => acc + (d.wonValueEur ?? 0), 0);
|
||||
|
||||
return {
|
||||
weightedPipelineEur: Math.round(weightedPipeline),
|
||||
openDealCount: open.length,
|
||||
wonCountQuarter: wonThisQuarter.length,
|
||||
wonValueQuarterEur: wonValueQuarter,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure.input(DealSchemas.createDealInput).mutation(async ({ ctx, input }) => {
|
||||
const areaId = await getSalesAreaId(ctx.db);
|
||||
const [created] = await ctx.db
|
||||
.insert(deal)
|
||||
.values({
|
||||
areaId,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
valueEur: input.valueEur,
|
||||
probability: input.probability,
|
||||
stage: input.stage,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
updateStage: protectedProcedure
|
||||
.input(DealSchemas.updateDealStageInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(deal)
|
||||
.set({
|
||||
stage: input.stage,
|
||||
enteredStageAt: new Date(),
|
||||
wonAt: input.stage === 'abschluss' ? new Date() : null,
|
||||
wonValueEur:
|
||||
input.stage === 'abschluss'
|
||||
? sql`COALESCE(${deal.valueEur} * ${deal.probability} / 100, 0)`
|
||||
: null,
|
||||
})
|
||||
.where(eq(deal.id, input.dealId))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Deal nicht gefunden.' });
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
export const gtmRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ product: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.product) {
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(gtmStep)
|
||||
.where(eq(gtmStep.product, input.product))
|
||||
.orderBy(gtmStep.order);
|
||||
}
|
||||
return ctx.db.select().from(gtmStep).orderBy(gtmStep.product, gtmStep.order);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
product: z.string().min(1).max(100),
|
||||
title: z.string().min(1).max(200),
|
||||
ownerUserId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db
|
||||
.select()
|
||||
.from(gtmStep)
|
||||
.where(eq(gtmStep.product, input.product));
|
||||
const [created] = await ctx.db
|
||||
.insert(gtmStep)
|
||||
.values({
|
||||
product: input.product,
|
||||
title: input.title,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
order: existing.length,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
setStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum(['open', 'in_progress', 'done']),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(gtmStep)
|
||||
.set({ status: input.status })
|
||||
.where(eq(gtmStep.id, input.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
// linter
|
||||
void sum;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, desc } from 'drizzle-orm';
|
||||
import { project, milestone, area, user, type Project } from '@cockpit/db';
|
||||
import { ProjectSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
|
||||
const byAreaInput = z.object({
|
||||
slug: z.string().min(1),
|
||||
includeArchived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const byIdInput = z.object({ id: z.string().uuid() });
|
||||
|
||||
export const projectRouter = router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
project: project,
|
||||
area: area,
|
||||
verantwortliche: user,
|
||||
})
|
||||
.from(project)
|
||||
.innerJoin(area, eq(project.areaId, area.id))
|
||||
.leftJoin(user, eq(user.id, project.verantwortlicheUserId))
|
||||
.where(eq(project.archiviert, false));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
byAreaSlug: protectedProcedure.input(byAreaInput).query(async ({ ctx, input }) => {
|
||||
const a = (await ctx.db.select().from(area).where(eq(area.slug, input.slug)).limit(1))[0];
|
||||
if (!a) return [];
|
||||
const projects = await ctx.db
|
||||
.select()
|
||||
.from(project)
|
||||
.where(
|
||||
input.includeArchived
|
||||
? eq(project.areaId, a.id)
|
||||
: and(eq(project.areaId, a.id), eq(project.archiviert, false)),
|
||||
)
|
||||
.orderBy(desc(project.createdAt));
|
||||
return projects;
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(byIdInput).query(async ({ ctx, input }) => {
|
||||
const rows = await ctx.db
|
||||
.select({ project: project, area: area })
|
||||
.from(project)
|
||||
.innerJoin(area, eq(area.id, project.areaId))
|
||||
.where(eq(project.id, input.id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
|
||||
const ms = await ctx.db.select().from(milestone).where(eq(milestone.projectId, row.project.id));
|
||||
return { project: row.project, area: row.area, milestones: ms };
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(ProjectSchemas.createProjectInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(project)
|
||||
.values({
|
||||
areaId: input.areaId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
verantwortlicheUserId: input.verantwortlicheUserId ?? null,
|
||||
arbeitsmodus: input.arbeitsmodus ?? null,
|
||||
outlineSteckbriefUrl: input.outlineSteckbriefUrl,
|
||||
planeProjectUrl: input.planeProjectUrl,
|
||||
risikolisteUrl: input.risikolisteUrl ?? null,
|
||||
dodText: input.dodText ?? null,
|
||||
start: input.start ?? null,
|
||||
deadline: input.deadline ?? null,
|
||||
createdBy: ctx.user.id,
|
||||
pflichtMinimumState: {
|
||||
steckbrief_freigegeben: false,
|
||||
verantwortlicher: !!input.verantwortlicheUserId,
|
||||
plane_board: true,
|
||||
meilensteine: input.milestones.length > 0,
|
||||
status_aktuell: false,
|
||||
retrospektive: false,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('Projekt konnte nicht angelegt werden.');
|
||||
|
||||
if (input.milestones.length > 0) {
|
||||
await tx.insert(milestone).values(
|
||||
input.milestones.map((m, idx) => ({
|
||||
projectId: created.id,
|
||||
title: m.title,
|
||||
due: m.due ?? null,
|
||||
order: idx,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.input(ProjectSchemas.archiveProjectInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const p = (
|
||||
await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1)
|
||||
)[0];
|
||||
if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
|
||||
if (p.verantwortlicheUserId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Nur die/der Projektverantwortliche darf das Projekt abschließen.',
|
||||
});
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(project)
|
||||
.set({
|
||||
archiviert: true,
|
||||
archiviertAm: new Date(),
|
||||
lessonsLearnedUrl: input.lessonsLearnedUrl,
|
||||
pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true },
|
||||
})
|
||||
.where(eq(project.id, input.projectId))
|
||||
.returning();
|
||||
return updated as Project;
|
||||
}),
|
||||
|
||||
setMilestoneStatus: protectedProcedure
|
||||
.input(ProjectSchemas.setMilestoneStatusInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(milestone)
|
||||
.set({ status: input.status })
|
||||
.where(eq(milestone.id, input.milestoneId))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
listArchived: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db
|
||||
.select()
|
||||
.from(project)
|
||||
.where(eq(project.archiviert, true))
|
||||
.orderBy(desc(project.archiviertAm));
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq, isNotNull, isNull, count } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, 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';
|
||||
|
||||
export const pulsRouter = router({
|
||||
overview: protectedProcedure.query(async ({ ctx }) => {
|
||||
const todayDate = todayIso();
|
||||
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows] =
|
||||
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)),
|
||||
ctx.db
|
||||
.select({ userId: dayEntry.userId, status: dayEntry.status })
|
||||
.from(dayEntry)
|
||||
.where(eq(dayEntry.date, todayDate)),
|
||||
ctx.db.select({ c: count() }).from(user).where(eq(user.active, true)),
|
||||
ctx.db
|
||||
.select({
|
||||
id: dayEntry.id,
|
||||
userId: dayEntry.userId,
|
||||
userName: user.name,
|
||||
date: dayEntry.date,
|
||||
ampel: dayEntry.ampel,
|
||||
trend: dayEntry.trend,
|
||||
reflectionText: dayEntry.reflectionText,
|
||||
publishedAt: dayEntry.publishedAt,
|
||||
})
|
||||
.from(dayEntry)
|
||||
.innerJoin(user, eq(user.id, dayEntry.userId))
|
||||
.where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full')))
|
||||
.orderBy(dayEntry.publishedAt)
|
||||
.limit(50),
|
||||
]);
|
||||
|
||||
const projectsByArea = new Map<string, Project[]>();
|
||||
for (const p of allProjects) {
|
||||
const list = projectsByArea.get(p.areaId) ?? [];
|
||||
list.push(p);
|
||||
projectsByArea.set(p.areaId, list);
|
||||
}
|
||||
|
||||
const areaSummaries = areas.map((a: Area) => {
|
||||
const projs = projectsByArea.get(a.id) ?? [];
|
||||
const agg = aggregateAreaAmpel(projs);
|
||||
return {
|
||||
id: a.id,
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
ampel: agg.ampel,
|
||||
counts: agg.counts,
|
||||
};
|
||||
});
|
||||
|
||||
const presentToday = new Set(todayPresence.map((p) => p.userId));
|
||||
const usersAll = await ctx.db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.active, true));
|
||||
const fehlende = usersAll.filter((u) => !presentToday.has(u.id));
|
||||
|
||||
return {
|
||||
date: todayDate,
|
||||
areas: areaSummaries,
|
||||
blockers: openBlockers.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
openedAt: b.openedAt,
|
||||
areaId: b.areaId,
|
||||
projectId: b.projectId,
|
||||
})),
|
||||
heuteQuote: {
|
||||
gepingt: presentToday.size,
|
||||
gesamt: totalActiveUsers[0]?.c ?? 0,
|
||||
fehlende,
|
||||
},
|
||||
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
|
||||
pflichtMinimumOffen: allProjects
|
||||
.filter((p) => Object.values(p.pflichtMinimumState).some((v) => v === false))
|
||||
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId })),
|
||||
feed: feedRows,
|
||||
gestern: previousWerktag(todayDate),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
function nextJourfixe(todayIsoDate: string): string {
|
||||
// Nächster Dienstag (Default Jourfixe). Wenn heute Dienstag, dann heute.
|
||||
const d = new Date(`${todayIsoDate}T00:00:00Z`);
|
||||
const wd = d.getUTCDay(); // 0=So..6=Sa
|
||||
const offset = (2 - wd + 7) % 7 || 0; // 2=Di
|
||||
d.setUTCDate(d.getUTCDate() + offset);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { user, dayEntry, absenceDay } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import {
|
||||
todayIso,
|
||||
isWerktag,
|
||||
previousWerktag,
|
||||
parseIsoDate,
|
||||
formatIsoDate,
|
||||
} from '../lib/werktag.js';
|
||||
|
||||
async function computeStreak(db: import('@cockpit/db').Db, userId: string): Promise<number> {
|
||||
const today = todayIso();
|
||||
|
||||
// Hole alle relevanten DayEntries und Abwesenheiten der letzten 90 Tage.
|
||||
const endIso = today;
|
||||
const start = parseIsoDate(today);
|
||||
start.setUTCDate(start.getUTCDate() - 120);
|
||||
const startIso = formatIsoDate(start);
|
||||
|
||||
const [entries, absences] = await Promise.all([
|
||||
db
|
||||
.select({ date: dayEntry.date, status: dayEntry.status })
|
||||
.from(dayEntry)
|
||||
.where(
|
||||
and(eq(dayEntry.userId, userId), gte(dayEntry.date, startIso), lte(dayEntry.date, endIso)),
|
||||
),
|
||||
db
|
||||
.select({ date: absenceDay.date })
|
||||
.from(absenceDay)
|
||||
.where(
|
||||
and(
|
||||
eq(absenceDay.userId, userId),
|
||||
gte(absenceDay.date, startIso),
|
||||
lte(absenceDay.date, endIso),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const fullByDate = new Map<string, boolean>();
|
||||
for (const e of entries) fullByDate.set(e.date, e.status === 'full');
|
||||
const absSet = new Set(absences.map((a) => a.date));
|
||||
|
||||
// Zähle aufeinanderfolgende volle Werktage rückwärts ab heute.
|
||||
let cursor = today;
|
||||
let streak = 0;
|
||||
// Wenn heute noch nicht voll: starten beim vorherigen Werktag.
|
||||
if (!fullByDate.get(today)) {
|
||||
cursor = previousWerktag(today);
|
||||
}
|
||||
while (true) {
|
||||
if (!isWerktag(cursor)) break;
|
||||
if (absSet.has(cursor)) {
|
||||
cursor = previousWerktag(cursor);
|
||||
continue;
|
||||
}
|
||||
if (fullByDate.get(cursor)) {
|
||||
streak++;
|
||||
cursor = previousWerktag(cursor);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export const userRouter = router({
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const streak = await computeStreak(ctx.db, ctx.user.id);
|
||||
return {
|
||||
id: ctx.user.id,
|
||||
name: ctx.user.name,
|
||||
email: ctx.user.email,
|
||||
streak,
|
||||
};
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.active, true));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
markAbsent: protectedProcedure
|
||||
.input(DayEntrySchemas.markAbsentInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.to < input.from) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Endedatum vor Anfangsdatum.' });
|
||||
}
|
||||
// Enumeriere Werktage zwischen from und to (inkl.) und füge sie als absence_day ein.
|
||||
let cursor = input.from;
|
||||
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
|
||||
[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (cursor <= input.to) {
|
||||
if (isWerktag(cursor)) {
|
||||
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });
|
||||
}
|
||||
const next = parseIsoDate(cursor);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
cursor = formatIsoDate(next);
|
||||
}
|
||||
if (toInsert.length === 0) return { inserted: 0 };
|
||||
await ctx.db.insert(absenceDay).values(toInsert).onConflictDoNothing();
|
||||
return { inserted: toInsert.length };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user