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,37 @@
|
||||
# --- builder ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /repo
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/db ./packages/db
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm --filter api build
|
||||
|
||||
# --- runtime ---
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY --from=builder /repo/pnpm-workspace.yaml /repo/package.json /repo/pnpm-lock.yaml ./
|
||||
COPY --from=builder /repo/apps/api/package.json ./apps/api/
|
||||
COPY --from=builder /repo/packages/db ./packages/db
|
||||
COPY --from=builder /repo/packages/shared ./packages/shared
|
||||
COPY --from=builder /repo/apps/api/dist ./apps/api/dist
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /app/apps/api
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@cockpit/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./router": "./src/routers/_app.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=../../.env src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cockpit/db": "workspace:*",
|
||||
"@cockpit/shared": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@trpc/server": "^11.0.0-rc.660",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"fastify": "^5.2.0",
|
||||
"openid-client": "^6.1.7",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Fastify-Decorator: macht `req.cockpitUser` verfügbar, wenn die Session gültig ist.
|
||||
// Die Typaugmentation lebt in `../types.ts`, damit sie auch in tRPC-Imports
|
||||
// gezogen wird (web → api Type-Only-Import).
|
||||
|
||||
import '../types.js';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { getSessionWithUser } from './session.js';
|
||||
|
||||
export async function registerAuthMiddleware(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
app.decorateRequest('cockpitUser', null);
|
||||
app.decorateRequest('cockpitSessionId', null);
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (!sessionId) return;
|
||||
const found = await getSessionWithUser(sessionId);
|
||||
if (found) {
|
||||
req.cockpitUser = found.user;
|
||||
req.cockpitSessionId = sessionId;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Authentik OIDC via openid-client v6. Authorization Code + PKCE.
|
||||
|
||||
import * as oidc from 'openid-client';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
let cachedConfig: oidc.Configuration | undefined;
|
||||
|
||||
export async function getOidcConfig(): Promise<oidc.Configuration> {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
const env = loadEnv();
|
||||
cachedConfig = await oidc.discovery(
|
||||
new URL(env.AUTHENTIK_ISSUER),
|
||||
env.AUTHENTIK_CLIENT_ID,
|
||||
env.AUTHENTIK_CLIENT_SECRET,
|
||||
);
|
||||
logger.info({ issuer: env.AUTHENTIK_ISSUER }, 'oidc discovery completed');
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export interface PendingFlow {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export async function buildAuthorizationUrl(returnTo: string): Promise<{
|
||||
url: URL;
|
||||
pending: PendingFlow;
|
||||
}> {
|
||||
const config = await getOidcConfig();
|
||||
const env = loadEnv();
|
||||
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const nonce = oidc.randomNonce();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: env.AUTHENTIK_REDIRECT_URI,
|
||||
scope: 'openid profile email groups',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
pending: { state, nonce, codeVerifier, returnTo },
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcUserInfo {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export async function completeAuthorization(
|
||||
callbackUrl: URL,
|
||||
pending: PendingFlow,
|
||||
): Promise<OidcUserInfo> {
|
||||
const config = await getOidcConfig();
|
||||
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims) {
|
||||
throw new Error('OIDC-Token enthält keine Claims.');
|
||||
}
|
||||
|
||||
const sub = String(claims.sub ?? '');
|
||||
const email = String(claims.email ?? '');
|
||||
const name = String(claims.name ?? claims.preferred_username ?? email);
|
||||
const rawGroups = claims.groups;
|
||||
const groups: string[] = Array.isArray(rawGroups)
|
||||
? rawGroups.filter((g): g is string => typeof g === 'string')
|
||||
: [];
|
||||
|
||||
if (!sub || !email) {
|
||||
throw new Error('OIDC-Claims unvollständig (sub oder email fehlt).');
|
||||
}
|
||||
|
||||
return { sub, email, name, groups };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { buildAuthorizationUrl, completeAuthorization, type PendingFlow } from './oidc.js';
|
||||
import { createSession, deleteSession } from './session.js';
|
||||
import { upsertUserFromOidc } from './user.js';
|
||||
|
||||
const PENDING_COOKIE = 'cockpit_oidc_pending';
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, sessionId: string): void {
|
||||
const env = loadEnv();
|
||||
const maxAge = env.SESSION_TTL_HOURS * 60 * 60;
|
||||
reply.setCookie(env.SESSION_COOKIE_NAME, sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
signed: false,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookie(reply: FastifyReply): void {
|
||||
const env = loadEnv();
|
||||
reply.clearCookie(env.SESSION_COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
function setPendingCookie(reply: FastifyReply, pending: PendingFlow): void {
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: loadEnv().NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 Minuten für den Round-Trip
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingCookie(req: FastifyRequest): PendingFlow | null {
|
||||
const raw = req.cookies[PENDING_COOKIE];
|
||||
if (!raw) return null;
|
||||
const unsigned = req.unsignCookie(raw);
|
||||
if (!unsigned.valid || !unsigned.value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(unsigned.value) as PendingFlow;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
// Startet den OIDC-Flow.
|
||||
app.get('/api/auth/login', async (req, reply) => {
|
||||
const returnTo =
|
||||
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
|
||||
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
|
||||
: '/puls';
|
||||
|
||||
const { url, pending } = await buildAuthorizationUrl(returnTo);
|
||||
setPendingCookie(reply, pending);
|
||||
return reply.redirect(url.toString(), 302);
|
||||
});
|
||||
|
||||
// OIDC-Callback. Tauscht den Code gegen Tokens und legt eine Session an.
|
||||
app.get('/api/auth/callback', async (req, reply) => {
|
||||
const pending = readPendingCookie(req);
|
||||
if (!pending) {
|
||||
logger.warn('callback ohne pending-cookie');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=session_expired`, 302);
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(env.AUTHENTIK_REDIRECT_URI);
|
||||
for (const [k, v] of Object.entries(req.query as Record<string, string>)) {
|
||||
callbackUrl.searchParams.set(k, v);
|
||||
}
|
||||
|
||||
const oidcUser = await completeAuthorization(callbackUrl, pending);
|
||||
const userRow = await upsertUserFromOidc(oidcUser);
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
setSessionCookie(reply, newSession.id);
|
||||
reply.clearCookie(PENDING_COOKIE, { path: '/' });
|
||||
|
||||
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'oidc callback failed');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=oidc_failed`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (sessionId) {
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Dev-Auto-Login: legt sofort eine Session für den Admin-Dejan an, sodass
|
||||
// die App ohne Authentik durchnavigierbar ist. Authentik kommt in der späteren
|
||||
// Roadmap. Nur aktiv bei NODE_ENV=development.
|
||||
if (env.NODE_ENV === 'development') {
|
||||
const handler = async (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => {
|
||||
const { getDb } = await import('../db.js');
|
||||
const { user } = await import('@cockpit/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const { db } = getDb();
|
||||
|
||||
const found = (await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1))[0];
|
||||
const userRow = found
|
||||
? found
|
||||
: await upsertUserFromOidc({
|
||||
sub: 'local-dejan',
|
||||
email: 'dejan@tool-ctrl.de',
|
||||
name: 'Dejan Cosic',
|
||||
groups: ['entwicklung'],
|
||||
});
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
setSessionCookie(reply, newSession.id);
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
|
||||
};
|
||||
app.get('/api/auth/dev-login', handler);
|
||||
app.post('/api/auth/dev-login', handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Session-Store in Postgres. Browser sieht nur `session_id` als HttpOnly-Cookie.
|
||||
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { session, user, type Session, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import { loadEnv } from '../env.js';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<Session> {
|
||||
const env = loadEnv();
|
||||
const { db } = getDb();
|
||||
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000);
|
||||
|
||||
const [created] = await db
|
||||
.insert(session)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
expiresAt,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Session konnte nicht angelegt werden.');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function getSessionWithUser(
|
||||
sessionId: string,
|
||||
): Promise<{ session: Session; user: User } | null> {
|
||||
const { db } = getDb();
|
||||
const rows = await db
|
||||
.select({ session, user })
|
||||
.from(session)
|
||||
.innerJoin(user, eq(session.userId, user.id))
|
||||
.where(eq(session.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.session.expiresAt.getTime() <= Date.now()) {
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const { db } = getDb();
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
}
|
||||
|
||||
export async function pruneExpiredSessions(): Promise<number> {
|
||||
const { db } = getDb();
|
||||
const result = await db.delete(session).where(lt(session.expiresAt, new Date()));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { user, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import type { OidcUserInfo } from './oidc.js';
|
||||
|
||||
// MVP: Group-Claim → default_area_id wird in Story 1.x mit echter Area-Tabelle
|
||||
// gemappt. Aktuell speichern wir den User mit `default_area_id = null` und
|
||||
// pflegen den Bereich später manuell. (OQ-1.)
|
||||
export async function upsertUserFromOidc(info: OidcUserInfo): Promise<User> {
|
||||
const { db } = getDb();
|
||||
|
||||
const existing = await db.select().from(user).where(eq(user.authentikSub, info.sub)).limit(1);
|
||||
if (existing[0]) {
|
||||
const [updated] = await db
|
||||
.update(user)
|
||||
.set({
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, existing[0].id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('User-Update fehlgeschlagen.');
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
authentikSub: info.sub,
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('User-Anlage fehlgeschlagen.');
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { getDb } from '../db.js';
|
||||
import { publishYesterdaysEntries } from '../routers/day-entry.js';
|
||||
import { previousWerktag, todayIso } from '../lib/werktag.js';
|
||||
|
||||
function authorize(req: FastifyRequest): void {
|
||||
const env = loadEnv();
|
||||
const header = req.headers.authorization ?? '';
|
||||
const expected = `Bearer ${env.CRON_BEARER_TOKEN}`;
|
||||
if (header !== expected) {
|
||||
throw new Error('Unauthorized cron call.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerCronRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/internal/cron/publish-day-entries', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
logger.warn({ err }, 'unauthorized cron call');
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
const { db } = getDb();
|
||||
const today = todayIso();
|
||||
const yesterday = previousWerktag(today);
|
||||
const updated = await publishYesterdaysEntries({ db }, yesterday);
|
||||
logger.info({ updated, date: yesterday }, 'published yesterday entries');
|
||||
return { ok: true, updated, date: yesterday };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/recompute-streaks', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
// Streaks werden lazy in `user.me` berechnet. Dieser Endpoint ist Reserve
|
||||
// für künftige denormalisierte Caches (NFR-1 Performance, sobald viele User).
|
||||
return { ok: true, note: 'streaks are computed lazily' };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/recompute-pflicht-minimum-5', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
const { db } = getDb();
|
||||
const { recomputePflichtMinimumStatusActual } = await import('../lib/pflicht-minimum.js');
|
||||
const res = await recomputePflichtMinimumStatusActual(db);
|
||||
logger.info(res, 'pflicht-minimum status_aktuell recomputed');
|
||||
return { ok: true, ...res };
|
||||
});
|
||||
|
||||
app.post('/internal/cron/pg-dump-backup', async (req, reply) => {
|
||||
try {
|
||||
authorize(req);
|
||||
} catch {
|
||||
reply.code(401);
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
// pg_dump läuft als System-Cron *im Postgres-Container* (siehe
|
||||
// docs/operations.md § Backups). Dieser Endpoint ist der Audit-Trigger:
|
||||
// wenn er gerufen wird, ist der Backup-Cron lebendig und der Logeintrag
|
||||
// landet im API-Log (zentrale Beobachtbarkeit).
|
||||
logger.info('pg-dump-backup heartbeat received');
|
||||
return {
|
||||
ok: true,
|
||||
note: 'pg_dump runs in postgres container; see docs/operations.md',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createDbClient, type Db } from '@cockpit/db';
|
||||
import type pg from 'pg';
|
||||
import { loadEnv } from './env.js';
|
||||
|
||||
let cached: { db: Db; pool: pg.Pool } | undefined;
|
||||
|
||||
export function getDb(): { db: Db; pool: pg.Pool } {
|
||||
if (cached) return cached;
|
||||
const env = loadEnv();
|
||||
cached = createDbClient({ connectionString: env.DATABASE_URL });
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
PORT: z.coerce.number().int().default(3000),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
AUTHENTIK_ISSUER: z.string().url(),
|
||||
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
||||
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
|
||||
AUTHENTIK_REDIRECT_URI: z.string().url(),
|
||||
|
||||
SESSION_COOKIE_NAME: z.string().default('cockpit_session'),
|
||||
SESSION_TTL_HOURS: z.coerce.number().int().min(1).default(24),
|
||||
SESSION_COOKIE_SECRET: z.string().min(32),
|
||||
|
||||
CRON_BEARER_TOKEN: z.string().min(8),
|
||||
|
||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:5173'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
let cached: Env | undefined;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
if (cached) return cached;
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Ungültige Umgebung:\n${issues}`);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Ampel-Aggregations-Algorithmus (FR-1a).
|
||||
|
||||
export type Ampel = 'green' | 'amber' | 'red';
|
||||
export type AreaAmpel = Ampel | 'grey';
|
||||
|
||||
export interface ProjectStatusLike {
|
||||
statusCache: Ampel | null;
|
||||
pflichtMinimumState: Record<string, boolean>;
|
||||
archiviert: boolean;
|
||||
}
|
||||
|
||||
export function aggregateAreaAmpel(projects: ProjectStatusLike[]): {
|
||||
ampel: AreaAmpel;
|
||||
counts: { green: number; amber: number; red: number; grey: number };
|
||||
} {
|
||||
const active = projects.filter((p) => !p.archiviert);
|
||||
if (active.length === 0)
|
||||
return { ampel: 'grey', counts: { green: 0, amber: 0, red: 0, grey: 0 } };
|
||||
|
||||
let red = 0;
|
||||
let amber = 0;
|
||||
let green = 0;
|
||||
let grey = 0;
|
||||
for (const p of active) {
|
||||
// Pflicht-Minimum-Verstoss hebt mindestens auf gelb.
|
||||
const hasMissingPflicht = Object.entries(p.pflichtMinimumState).some(([_, v]) => v === false);
|
||||
let s: Ampel | 'grey' = p.statusCache ?? 'grey';
|
||||
if (s === 'green' && hasMissingPflicht) s = 'amber';
|
||||
if (s === 'red') red++;
|
||||
else if (s === 'amber') amber++;
|
||||
else if (s === 'green') green++;
|
||||
else grey++;
|
||||
}
|
||||
|
||||
if (red > 0) return { ampel: 'red', counts: { green, amber, red, grey } };
|
||||
if (amber > 0) return { ampel: 'amber', counts: { green, amber, red, grey } };
|
||||
if (green > 0) return { ampel: 'green', counts: { green, amber, red, grey } };
|
||||
return { ampel: 'grey', counts: { green, amber, red, grey } };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
base: { service: 'cockpit-api' },
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Pflicht-Minimum-Berechnung (Leitfaden §9).
|
||||
// Punkte 1–4 + 6 werden manuell in der App gepflegt; Punkt 5 (Status-Ampel aktuell)
|
||||
// wird hier zeitabhängig berechnet und im Cron auf alle Projekte aktualisiert.
|
||||
|
||||
import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm';
|
||||
import { project, dayEntry } from '@cockpit/db';
|
||||
import { werktageBetween, todayIso } from './werktag.js';
|
||||
|
||||
const MAX_AGE_WERKTAGE = 7;
|
||||
|
||||
export async function recomputePflichtMinimumStatusActual(
|
||||
db: import('@cockpit/db').Db,
|
||||
): Promise<{ updated: number }> {
|
||||
const projects = await db.select().from(project).where(eq(project.archiviert, false));
|
||||
const today = todayIso();
|
||||
let updated = 0;
|
||||
for (const p of projects) {
|
||||
// Letzte Reflexion mit Projektbezug? (Vereinfachung: letzter day_entry mit Ampel,
|
||||
// dessen Update das Projekt-Status-Cache gesetzt hat = `status_cache` selbst.)
|
||||
const lastEntry = (
|
||||
await db
|
||||
.select()
|
||||
.from(dayEntry)
|
||||
.where(and(isNotNull(dayEntry.reflectedAt)))
|
||||
.orderBy(desc(dayEntry.reflectedAt))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false;
|
||||
|
||||
const newState = { ...p.pflichtMinimumState, status_aktuell: actual };
|
||||
if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) {
|
||||
await db.update(project).set({ pflichtMinimumState: newState }).where(eq(project.id, p.id));
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
// linter
|
||||
void isNull;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatIsoDate, isWerktag, nextWerktag, previousWerktag, werktageBetween } from './werktag';
|
||||
|
||||
describe('werktag', () => {
|
||||
it('formatIsoDate liefert YYYY-MM-DD in Berlin TZ', () => {
|
||||
const d = new Date(Date.UTC(2026, 4, 25, 8, 30));
|
||||
expect(formatIsoDate(d)).toBe('2026-05-25');
|
||||
});
|
||||
|
||||
it('isWerktag erkennt Mo-Fr', () => {
|
||||
expect(isWerktag('2026-05-25')).toBe(true); // Mo
|
||||
expect(isWerktag('2026-05-29')).toBe(true); // Fr
|
||||
expect(isWerktag('2026-05-30')).toBe(false); // Sa
|
||||
expect(isWerktag('2026-05-31')).toBe(false); // So
|
||||
});
|
||||
|
||||
it('nextWerktag springt über Wochenende', () => {
|
||||
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
|
||||
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
|
||||
});
|
||||
|
||||
it('previousWerktag springt über Wochenende', () => {
|
||||
expect(previousWerktag('2026-06-01')).toBe('2026-05-29'); // Mo → Fr
|
||||
});
|
||||
|
||||
it('werktageBetween zählt korrekt', () => {
|
||||
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
|
||||
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
|
||||
// (Frontend bekommt fertige Felder).
|
||||
|
||||
const TIMEZONE = 'Europe/Berlin';
|
||||
|
||||
/**
|
||||
* Gibt das aktuelle Datum (oder `now`) als `YYYY-MM-DD` in Europe/Berlin zurück.
|
||||
*/
|
||||
export function todayIso(now: Date = new Date()): string {
|
||||
return formatIsoDate(now);
|
||||
}
|
||||
|
||||
export function formatIsoDate(d: Date): string {
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return fmt.format(d); // en-CA liefert ISO YYYY-MM-DD
|
||||
}
|
||||
|
||||
export function parseIsoDate(s: string): Date {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
throw new Error(`Ungültiges ISO-Datum: ${s}`);
|
||||
}
|
||||
const [y, m, d] = s.split('-').map(Number) as [number, number, number];
|
||||
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); // 12:00 UTC vermeidet TZ-Drift
|
||||
}
|
||||
|
||||
/**
|
||||
* Mo-Fr = Werktag. Feiertage Phase 2.
|
||||
*/
|
||||
export function isWerktag(s: string): boolean {
|
||||
const d = parseIsoDate(s);
|
||||
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
|
||||
return wd !== 'Sat' && wd !== 'Sun';
|
||||
}
|
||||
|
||||
export function nextWerktag(s: string): string {
|
||||
let cur = parseIsoDate(s);
|
||||
do {
|
||||
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
|
||||
} while (!isWerktag(formatIsoDate(cur)));
|
||||
return formatIsoDate(cur);
|
||||
}
|
||||
|
||||
export function previousWerktag(s: string): string {
|
||||
let cur = parseIsoDate(s);
|
||||
do {
|
||||
cur = new Date(cur.getTime() - 24 * 60 * 60 * 1000);
|
||||
} while (!isWerktag(formatIsoDate(cur)));
|
||||
return formatIsoDate(cur);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt Werktage zwischen zwei ISO-Daten (inkl. start, exkl. end).
|
||||
*/
|
||||
export function werktageBetween(startIso: string, endIso: string): number {
|
||||
let cur = parseIsoDate(startIso);
|
||||
const end = parseIsoDate(endIso);
|
||||
let count = 0;
|
||||
while (cur.getTime() < end.getTime()) {
|
||||
if (isWerktag(formatIsoDate(cur))) count++;
|
||||
cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { loadEnv } from './env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { registerAuthMiddleware } from './auth/middleware.js';
|
||||
import { registerAuthRoutes } from './auth/routes.js';
|
||||
import { registerCronRoutes } from './cron/routes.js';
|
||||
import { getDb } from './db.js';
|
||||
import { appRouter, type AppRouter } from './routers/_app.js';
|
||||
import { createContext } from './trpc.js';
|
||||
|
||||
async function main() {
|
||||
const env = loadEnv();
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
base: { service: 'cockpit-api' },
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cookie, {
|
||||
secret: env.SESSION_COOKIE_SECRET,
|
||||
});
|
||||
|
||||
await registerAuthMiddleware(app);
|
||||
await registerAuthRoutes(app);
|
||||
await registerCronRoutes(app);
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ path, error }) {
|
||||
logger.error({ path, err: error }, 'trpc error');
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
const { pool } = getDb();
|
||||
let dbOk = false;
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
dbOk = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'db ping failed');
|
||||
}
|
||||
|
||||
let authentikOk = false;
|
||||
try {
|
||||
const res = await fetch(new URL('.well-known/openid-configuration', env.AUTHENTIK_ISSUER), {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
authentikOk = res.ok;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'authentik ping failed');
|
||||
}
|
||||
|
||||
const status = dbOk && authentikOk ? 'ok' : 'degraded';
|
||||
return {
|
||||
status,
|
||||
db: dbOk ? 'ok' : 'down',
|
||||
authentik: authentikOk ? 'ok' : 'down',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/me', async (req, reply) => {
|
||||
if (!req.cockpitUser) {
|
||||
reply.code(401);
|
||||
return { error: 'Nicht angemeldet.' };
|
||||
}
|
||||
return {
|
||||
id: req.cockpitUser.id,
|
||||
name: req.cockpitUser.name,
|
||||
email: req.cockpitUser.email,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||
logger.info({ port: env.PORT }, 'api server started');
|
||||
} catch (err) {
|
||||
logger.error(err, 'failed to start api server');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,69 @@
|
||||
// tRPC v11 Core: Context, initTRPC, Procedure-Typen, Audit-Wrapper.
|
||||
|
||||
import './types.js';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { User } from '@cockpit/db';
|
||||
import { getDb } from './db.js';
|
||||
|
||||
export interface Context {
|
||||
user: User | null;
|
||||
sessionId: string | null;
|
||||
req: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
db: ReturnType<typeof getDb>['db'];
|
||||
}
|
||||
|
||||
export async function createContext(opts: {
|
||||
req: FastifyRequest;
|
||||
res: FastifyReply;
|
||||
}): Promise<Context> {
|
||||
const { db } = getDb();
|
||||
return {
|
||||
user: opts.req.cockpitUser,
|
||||
sessionId: opts.req.cockpitSessionId,
|
||||
req: opts.req,
|
||||
reply: opts.res,
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Du bist nicht angemeldet.',
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
// Audit-Wrapper: schreibt nach erfolgreicher Mutation einen audit_event.
|
||||
// Genutzt als zusätzliche Middleware auf Mutations.
|
||||
export function withAudit<TInput, TResult extends Record<string, unknown> | unknown>(
|
||||
action: string,
|
||||
) {
|
||||
return async function audit(opts: {
|
||||
ctx: Context;
|
||||
input: TInput;
|
||||
next: () => Promise<{ ok: true; data: TResult } | TResult>;
|
||||
}) {
|
||||
const result = await opts.next();
|
||||
if (!opts.ctx.user) return result;
|
||||
const { auditEvent } = await import('@cockpit/db');
|
||||
await opts.ctx.db.insert(auditEvent).values({
|
||||
actorUserId: opts.ctx.user.id,
|
||||
action,
|
||||
entityKind: action.split('.')[0] ?? 'unknown',
|
||||
payload: { input: opts.input as unknown },
|
||||
});
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Globale Fastify-Augmentations — wird von jeder Datei mitgezogen, die
|
||||
// `apps/api/src/server.ts` oder via Re-Export-Pfad in Reach kommt.
|
||||
|
||||
import type { User } from '@cockpit/db';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
cockpitUser: User | null;
|
||||
cockpitSessionId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# --- builder ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /repo
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/web ./apps/web
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/db ./packages/db
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# --- runtime (static via Caddy in production compose) ---
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY --from=builder /repo/apps/web/dist /srv
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/toolctrl-emblem.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/favicon.png" />
|
||||
<title>everythingbutwork — ToolCTRL</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cockpit/api": "workspace:*",
|
||||
"@cockpit/db": "workspace:*",
|
||||
"@cockpit/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@trpc/client": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "^11.0.0-rc.660",
|
||||
"@trpc/server": "^11.0.0-rc.660",
|
||||
"@fontsource/inter": "^5.1.1",
|
||||
"@fontsource/jetbrains-mono": "^5.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^7.1.1",
|
||||
"sonner": "^1.7.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 713.32 678.61">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-5);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: url(#linear-gradient-6);
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: url(#linear-gradient-4);
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: url(#linear-gradient-3);
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="658.96" y1="31.96" x2="297.7" y2="458.6" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#000"/>
|
||||
<stop offset="1" stop-color="#333"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="1684.3" y1="1142.75" x2="2206.09" y2="1553.51" gradientTransform="translate(-1833.74 -1108.96) rotate(5.06) skewX(14.25)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#b2b2b2"/>
|
||||
<stop offset=".78" stop-color="#fff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="1719.13" y1="1098.51" x2="2240.91" y2="1509.28" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-4" x1="1764.82" y1="1040.47" x2="2286.6" y2="1451.24" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-5" x1="1806.27" y1="987.82" x2="2328.05" y2="1398.59" xlink:href="#linear-gradient-2"/>
|
||||
<linearGradient id="linear-gradient-6" x1="345.03" y1="-10.15" x2="1014.55" y2="516.91" gradientTransform="matrix(1,0,0,1,0,0)" xlink:href="#linear-gradient-2"/>
|
||||
</defs>
|
||||
<g>
|
||||
<path d="M688.7,572.09l-160,98.95c-9.14,5.73-19.99,8.35-30.77,7.37l-380.68-34.36c-22.48-2.01-40.76-19.01-44.35-41.31L.64,159.31c-3.17-19.56,5.6-39.12,22.3-49.72L189.46,8.08c-13.95,9.75,23.77,31.55,26.88,49.95l91.3,361.13c6.21,36.68,36.37,64.58,73.42,67.93l278.64,42.9c8.65.79,21.87,45.68,28.94,42.03l.06.06Z"/>
|
||||
<path class="cls-6" d="M688.64,572.03c-7.07,3.66-15.23,5.36-23.88,4.57l-349-31.5c-37.04-3.35-67.21-31.26-73.42-67.93L170.39,53.35c-3.11-18.4,5.12-35.52,19.07-45.27l1.95-1.16c7.98-5.06,17.61-7.62,27.91-6.76l358.56,30.34c29.49,2.5,53.56,24.62,58.61,53.8l76.16,440.39c3.53,20.29-7.19,38.99-24.01,47.34Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-5" d="M300.22,343.93l-15.01,4.26,15.73,96.05c4.23,25.83,21.88,42.59,47.21,44.83l20.42,1.81-2.48-15.14-20.42-1.81c-15.81-1.4-26.04-11.44-28.79-28.28l-8.24-50.34,44.93,3.97-2.45-14.94-44.93-3.97-5.97-36.43Z"/>
|
||||
<path class="cls-4" d="M421.96,387.97c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM437.36,482.03c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
|
||||
<path class="cls-3" d="M541.37,398.53c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM556.77,492.59c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
|
||||
<polygon class="cls-1" points="607.67 368.41 631.1 511.46 647.04 512.87 622.68 364.15 607.67 368.41"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M582.39,164.07l18.3,1.62-56.29-75.12c-3.15-4.2-7.93-6.86-13.16-7.33h0c-7.61-.67-14.86,3.41-18.22,10.27l-30.3,61.75,19.2,1.7,27.57-56.2c.99-2.01,3.75-2.27,5.1-.48l47.81,63.79Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner';
|
||||
import { TrpcProvider } from './lib/trpc-provider';
|
||||
import { Router } from './lib/router';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<TrpcProvider>
|
||||
<Router />
|
||||
<Toaster position="bottom-right" richColors={false} />
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
export function BlockerDialog({ onClose }: { onClose: () => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.blocker.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.blockerOpened);
|
||||
await utils.puls.overview.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
mutation.mutate({ title: title.trim(), description: description.trim() || undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Was klemmt?</span>
|
||||
<input
|
||||
required
|
||||
ref={(el) => {
|
||||
if (el && !title) el.focus();
|
||||
}}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TopNav } from './TopNav';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
import { BlockerDialog } from '../blocker/BlockerDialog';
|
||||
|
||||
const SHORTCUT_MAP: Record<string, string> = {
|
||||
p: '/puls',
|
||||
h: '/heute',
|
||||
b: '/bereich',
|
||||
e: '/bereich/entwicklung',
|
||||
v: '/bereich/vertrieb',
|
||||
m: '/bereich/marketing',
|
||||
a: '/archiv',
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [blockerOpen, setBlockerOpen] = useState(false);
|
||||
|
||||
// Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen
|
||||
// zeigen wir die Login-Aufforderung mit dem Authentik-Link.
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && me === null) {
|
||||
// Kleiner Delay, damit der useQuery sicher fertig ist und wir nicht
|
||||
// mehrmals redirecten.
|
||||
const t = setTimeout(() => {
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.replace('/api/auth/dev-login');
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [me]);
|
||||
|
||||
// Keyboard-Shortcuts: `g <key>` → Navigation, `n` → Neuer Blocker.
|
||||
useEffect(() => {
|
||||
let waitingForSecond = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function reset() {
|
||||
waitingForSecond = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === '?' && !waitingForSecond) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'n' && !waitingForSecond) {
|
||||
setBlockerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!waitingForSecond && e.key.toLowerCase() === 'g') {
|
||||
waitingForSecond = true;
|
||||
timer = setTimeout(reset, 1500);
|
||||
return;
|
||||
}
|
||||
if (waitingForSecond) {
|
||||
const target = SHORTCUT_MAP[e.key.toLowerCase()];
|
||||
if (target) navigate(target);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
reset();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg)]">
|
||||
<a href="#main" className="skip-link">
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
<TopNav />
|
||||
<main id="main" className="mx-auto max-w-[1280px] px-6 py-8">
|
||||
{me ? <Outlet /> : <NotLoggedInHint />}
|
||||
</main>
|
||||
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotLoggedInHint() {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center shadow-[var(--shadow-sm)]">
|
||||
<p className="text-[var(--color-ink-muted)]">Du bist nicht angemeldet.</p>
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Über Authentik anmelden
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink, useLocation } from 'react-router';
|
||||
import { clsx } from 'clsx';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
|
||||
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
|
||||
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
|
||||
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
|
||||
] as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const me = useCurrentUser();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-30 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
|
||||
role="banner"
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
|
||||
aria-label="Hauptnavigation"
|
||||
>
|
||||
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
|
||||
<span>{voice.appName}</span>
|
||||
<ToolCtrlLogo size={18} />
|
||||
</NavLink>
|
||||
|
||||
<ul className="flex items-center gap-0.5 ml-6">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = location.pathname.startsWith(tab.matchPrefix);
|
||||
return (
|
||||
<li key={tab.to}>
|
||||
<NavLink
|
||||
to={tab.to}
|
||||
className={clsx(
|
||||
'inline-flex h-9 items-center rounded-[var(--radius-md)] px-3 text-sm font-medium',
|
||||
'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
|
||||
isActive &&
|
||||
'text-[var(--color-ink)] border-b-2 border-[var(--color-ink)] rounded-b-none',
|
||||
)}
|
||||
aria-keyshortcuts={tab.shortcut}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{me ? (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
me.streak > 0
|
||||
? 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]'
|
||||
: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
title={`Aktuelle Update-Serie: ${me.streak} ${me.streak === 1 ? 'Tag' : 'Tage'}`}
|
||||
>
|
||||
<Flame size={12} fill={me.streak > 0 ? 'currentColor' : 'none'} />
|
||||
<span className="font-mono tabular-nums">{me.streak}</span>
|
||||
</span>
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
|
||||
aria-label={me.name}
|
||||
title={me.name}
|
||||
>
|
||||
{getInitials(me.name)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="text-sm font-medium text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles: Record<Variant, string> = {
|
||||
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
|
||||
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
|
||||
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
|
||||
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
|
||||
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
|
||||
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'primary', size = 'md', className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(base, variantStyles[variant], sizeStyles[size], className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 pt-6 pb-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Body({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 py-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('px-6 pt-3 pb-6 border-t border-[var(--color-border)]', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Card = { Root, Header, Body, Footer };
|
||||
@@ -0,0 +1,28 @@
|
||||
// macOS-Ctrl-Caret als Brand-Akzent. Skalierbar via currentColor.
|
||||
|
||||
export function KeyCaret({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
>
|
||||
<polyline points="3 10 8 5 13 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function SkeletonCard({ className, lines = 3 }: { className?: string; lines?: number }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] p-6 animate-pulse',
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="h-4 w-1/3 bg-[var(--color-border)] rounded" />
|
||||
<div className="mt-3 space-y-2">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 bg-[var(--color-border)] rounded"
|
||||
style={{ width: `${100 - i * 12}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Brand-Emblem (das Tool-Ctrl-Tastenkappensymbol). Ersetzt den früher
|
||||
// verwendeten Caret als Marken-Anker im Logo-Lockup und in Streaks.
|
||||
|
||||
export function ToolCtrlLogo({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
alt = 'ToolCTRL',
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
alt?: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src="/toolctrl-emblem.svg"
|
||||
width={size}
|
||||
height={(size * 678) / 713}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
alt={ariaHidden ? '' : alt}
|
||||
draggable={false}
|
||||
style={{ display: 'inline-block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { trpc } from '../lib/trpc';
|
||||
|
||||
export function useCurrentUser() {
|
||||
const q = trpc.user.me.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return q.data ?? null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
|
||||
const Puls = lazy(() => import('../routes/puls').then((m) => ({ default: m.PulsRoute })));
|
||||
const Heute = lazy(() =>
|
||||
import('../routes/heute').then((m) => ({ default: m.HeuteRoute })),
|
||||
);
|
||||
const Reflexion = lazy(() =>
|
||||
import('../routes/reflexion').then((m) => ({ default: m.ReflexionRoute })),
|
||||
);
|
||||
const Bereich = lazy(() => import('../routes/bereich').then((m) => ({ default: m.BereichRoute })));
|
||||
const Projekt = lazy(() => import('../routes/projekt').then((m) => ({ default: m.ProjektRoute })));
|
||||
const Archiv = lazy(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })));
|
||||
const NotFound = lazy(() =>
|
||||
import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })),
|
||||
);
|
||||
const Login = lazy(() => import('../routes/login').then((m) => ({ default: m.LoginRoute })));
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
<Suspense fallback={<SkeletonCard className="mx-auto mt-12 max-w-2xl" />}>{node}</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: withSuspense(<Login />),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: withSuspense(<Puls />) },
|
||||
{ path: 'puls', element: withSuspense(<Puls />) },
|
||||
{ path: 'heute', element: withSuspense(<Heute />) },
|
||||
{ path: 'reflexion', element: withSuspense(<Reflexion />) },
|
||||
{ path: 'bereich', element: withSuspense(<Bereich />) },
|
||||
{ path: 'bereich/:slug', element: withSuspense(<Bereich />) },
|
||||
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
|
||||
{ path: 'archiv', element: withSuspense(<Archiv />) },
|
||||
{ path: '*', element: withSuspense(<NotFound />) },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function Router() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from './trpc';
|
||||
import { voice } from './voice';
|
||||
|
||||
export function TrpcProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
onError(err) {
|
||||
const msg =
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'message' in err &&
|
||||
typeof err.message === 'string'
|
||||
? err.message
|
||||
: voice.error.generic;
|
||||
toast(msg);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/trpc',
|
||||
fetch(url, options) {
|
||||
return fetch(url, { ...options, credentials: 'include' });
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { AppRouter } from '@cockpit/api/router';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
@@ -0,0 +1,59 @@
|
||||
// Zentrale UI-Mikrocopy — NFR-12/13 Voice & Tone.
|
||||
// Locker, direkt, deutsch. Keine Anglizismen, keine Corporate-Floskeln.
|
||||
|
||||
export const voice = {
|
||||
appName: 'everythingbutwork',
|
||||
caret: '^',
|
||||
toast: {
|
||||
checkedIn: 'Locked in.',
|
||||
reflected: 'Reflektiert.',
|
||||
blockerOpened: 'Blocker offen.',
|
||||
blockerResolved: 'Blocker erledigt.',
|
||||
projectCreated: 'Projekt angelegt.',
|
||||
projectArchived: 'Projekt archiviert.',
|
||||
saved: 'Gespeichert.',
|
||||
strong: 'Stark.',
|
||||
},
|
||||
cta: {
|
||||
checkIn: 'Lock in',
|
||||
reflect: 'Reflektieren',
|
||||
newBlocker: 'Neuer Blocker',
|
||||
newProject: 'Neues Projekt',
|
||||
},
|
||||
hint: {
|
||||
notCheckedInToday: 'Du hast heute noch nicht gepingt.',
|
||||
reflectionPending: 'Reflexion offen',
|
||||
notReflectedYet: 'Du hast heute noch nicht reflektiert.',
|
||||
howIsItGoing: 'Wie läuft es?',
|
||||
},
|
||||
empty: {
|
||||
feed: 'Hier erscheinen die Updates des Tages, sobald jemand reflektiert hat.',
|
||||
blockers: 'Keine offenen Blocker.',
|
||||
projects: 'Noch keine Projekte. Leg eines an.',
|
||||
archive: 'Noch nichts archiviert.',
|
||||
},
|
||||
error: {
|
||||
generic: 'Da ist etwas schiefgelaufen. Bitte nochmal versuchen.',
|
||||
notLoggedIn: 'Du bist nicht angemeldet.',
|
||||
notAllowed: 'Dafür hast du keine Berechtigung.',
|
||||
notFound: 'Nicht gefunden.',
|
||||
},
|
||||
label: {
|
||||
bereich: 'Bereich',
|
||||
bezug: 'Bezug',
|
||||
ampel: {
|
||||
green: 'Im Plan',
|
||||
amber: 'Gefährdet',
|
||||
red: 'Blockiert',
|
||||
},
|
||||
trend: {
|
||||
better: 'Besser',
|
||||
flat: 'Gleich',
|
||||
worse: 'Schlechter',
|
||||
},
|
||||
projektart: {
|
||||
kundenprojekt: 'Kundenprojekt',
|
||||
internesProjekt: 'Internes Projekt',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@fontsource/inter/latin-300.css';
|
||||
import '@fontsource/inter/latin-400.css';
|
||||
import '@fontsource/inter/latin-500.css';
|
||||
import '@fontsource/inter/latin-600.css';
|
||||
import '@fontsource/inter/latin-700.css';
|
||||
import '@fontsource/jetbrains-mono/latin-400.css';
|
||||
import './styles/globals.css';
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { voice } from '../lib/voice';
|
||||
|
||||
export function ArchivRoute() {
|
||||
const q = trpc.project.listArchived.useQuery();
|
||||
if (q.isPending) return <SkeletonCard lines={4} />;
|
||||
const items = q.data ?? [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">Archiv</h1>
|
||||
{items.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.archive}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((p) => (
|
||||
<Card.Root key={p.id}>
|
||||
<Card.Body className="flex items-center justify-between">
|
||||
<Link to={`/projekt/${p.id}`} className="font-medium hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
archiviert: {p.archiviertAm ? p.archiviertAm.toString().slice(0, 10) : '—'}
|
||||
</span>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Link, NavLink, useParams } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [
|
||||
{ slug: 'entwicklung', label: 'Entwicklung' },
|
||||
{ slug: 'vertrieb', label: 'Vertrieb' },
|
||||
{ slug: 'marketing', label: 'Marketing' },
|
||||
];
|
||||
|
||||
function BereichTabs({ active }: { active: string }) {
|
||||
return (
|
||||
<nav aria-label="Bereich wählen" className="flex gap-1 border-b border-[var(--color-border)]">
|
||||
{BEREICHE.map((b) => (
|
||||
<NavLink
|
||||
key={b.slug}
|
||||
to={`/bereich/${b.slug}`}
|
||||
className={clsx(
|
||||
'inline-flex h-10 items-center rounded-t-[var(--radius-md)] px-4 text-sm font-medium',
|
||||
active === b.slug
|
||||
? 'border-b-2 border-[var(--color-ink)] text-[var(--color-ink)]'
|
||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function BereichRoute() {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
// Default: Entwicklung, wenn /bereich ohne Slug.
|
||||
const effective = slug && BEREICHE.some((b) => b.slug === slug) ? slug : 'entwicklung';
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BereichTabs active={effective} />
|
||||
{effective === 'entwicklung' && <EntwicklungDetail />}
|
||||
{effective === 'vertrieb' && <VertriebDetail />}
|
||||
{effective === 'marketing' && <MarketingDetail />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntwicklungDetail() {
|
||||
const q = trpc.project.byAreaSlug.useQuery({ slug: 'entwicklung' });
|
||||
const areas = trpc.area.list.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={5} />;
|
||||
const projects = q.data ?? [];
|
||||
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
|
||||
const intern = projects.filter((p) => p.kind === 'internes_projekt');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
|
||||
</div>
|
||||
<Section title="Kundenprojekte" projects={kunden} />
|
||||
<Section title="Interne Projekte" projects={intern} />
|
||||
{open && (
|
||||
<ProjectCreateDialog
|
||||
onClose={() => setOpen(false)}
|
||||
areas={areas.data ?? []}
|
||||
defaultSlug="entwicklung"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
projects,
|
||||
}: {
|
||||
title: string;
|
||||
projects: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
statusCache: 'green' | 'amber' | 'red' | null;
|
||||
deadline: string | null;
|
||||
pflichtMinimumState: Record<string, boolean>;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">{title}</h2>
|
||||
{projects.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.projects}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{projects.map((p) => (
|
||||
<Link key={p.id} to={`/projekt/${p.id}`} className="contents">
|
||||
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
|
||||
<Card.Body className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{p.name}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
|
||||
</p>
|
||||
</div>
|
||||
<PflichtMinimumIndicator state={p.pflichtMinimumState} />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PflichtMinimumIndicator({ state }: { state: Record<string, boolean> }) {
|
||||
const points = [
|
||||
state.steckbrief_freigegeben,
|
||||
state.verantwortlicher,
|
||||
state.plane_board,
|
||||
state.meilensteine,
|
||||
state.status_aktuell,
|
||||
state.retrospektive,
|
||||
];
|
||||
const filled = points.filter(Boolean).length;
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={`Pflicht-Minimum ${filled}/6`}>
|
||||
<div className="flex gap-0.5">
|
||||
{points.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={clsx(
|
||||
'h-2 w-2 rounded-sm',
|
||||
p ? 'bg-[var(--color-ink)]' : 'bg-[var(--color-border)]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">{filled}/6</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCreateDialog({
|
||||
onClose,
|
||||
areas,
|
||||
defaultSlug,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
areas: Array<{ id: string; slug: string; name: string }>;
|
||||
defaultSlug: string;
|
||||
}) {
|
||||
const defaultArea = areas.find((a) => a.slug === defaultSlug);
|
||||
const [name, setName] = useState('');
|
||||
const [areaId, setAreaId] = useState(defaultArea?.id ?? '');
|
||||
const [kind, setKind] = useState<'kundenprojekt' | 'internes_projekt'>('internes_projekt');
|
||||
const [arbeitsmodus, setArbeitsmodus] = useState<'cycle' | 'kanban'>('kanban');
|
||||
const [steckbrief, setSteckbrief] = useState('');
|
||||
const [plane, setPlane] = useState('');
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const create = trpc.project.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.projectCreated);
|
||||
await utils.project.byAreaSlug.invalidate();
|
||||
await utils.puls.overview.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!areaId) return;
|
||||
create.mutate({
|
||||
areaId,
|
||||
name,
|
||||
kind,
|
||||
arbeitsmodus,
|
||||
outlineSteckbriefUrl: steckbrief,
|
||||
planeProjectUrl: plane,
|
||||
milestones: [],
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Neues Projekt</h2>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<Field label="Name">
|
||||
<input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bereich">
|
||||
<select
|
||||
required
|
||||
value={areaId}
|
||||
onChange={(e) => setAreaId(e.target.value)}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
{areas.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Projektart">
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as 'kundenprojekt' | 'internes_projekt')}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="kundenprojekt">Kundenprojekt</option>
|
||||
<option value="internes_projekt">Internes Projekt</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Arbeitsmodus">
|
||||
<select
|
||||
value={arbeitsmodus}
|
||||
onChange={(e) => setArbeitsmodus(e.target.value as 'cycle' | 'kanban')}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="kanban">Kanban</option>
|
||||
<option value="cycle">Cycle</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Outline-Steckbrief-URL">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={steckbrief}
|
||||
onChange={(e) => setSteckbrief(e.target.value)}
|
||||
placeholder="https://outline.tool-ctrl.de/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Plane-Board-URL">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={plane}
|
||||
onChange={(e) => setPlane(e.target.value)}
|
||||
placeholder="https://plane.tool-ctrl.de/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function VertriebDetail() {
|
||||
return <ProjektListeFuerBereich slug="vertrieb" />;
|
||||
}
|
||||
|
||||
function MarketingDetail() {
|
||||
return <ProjektListeFuerBereich slug="marketing" />;
|
||||
}
|
||||
|
||||
function ProjektListeFuerBereich({ slug }: { slug: 'vertrieb' | 'marketing' }) {
|
||||
const q = trpc.project.byAreaSlug.useQuery({ slug });
|
||||
const areas = trpc.area.list.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={5} />;
|
||||
const projects = q.data ?? [];
|
||||
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
|
||||
const intern = projects.filter((p) => p.kind === 'internes_projekt');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
|
||||
</div>
|
||||
<Section title="Kundenprojekte" projects={kunden} />
|
||||
<Section title="Interne Projekte" projects={intern} />
|
||||
{open && (
|
||||
<ProjectCreateDialog
|
||||
onClose={() => setOpen(false)}
|
||||
areas={areas.data ?? []}
|
||||
defaultSlug={slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { X, Plus, RotateCw } from 'lucide-react';
|
||||
|
||||
export function HeuteRoute() {
|
||||
const navigate = useNavigate();
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
|
||||
if (today.isPending) return <SkeletonCard lines={4} />;
|
||||
|
||||
if (today.data?.entry) {
|
||||
return <EinstempeltView todos={today.data.todos} entry={today.data.entry} />;
|
||||
}
|
||||
|
||||
return <CheckInForm onDone={() => navigate('/puls')} />;
|
||||
}
|
||||
|
||||
function CheckInForm({ onDone }: { onDone: () => void }) {
|
||||
const carryQuery = trpc.dayEntry.pendingCarryOver.useQuery();
|
||||
const [todos, setTodos] = useState<{ title: string; carried?: boolean }[]>([{ title: '' }]);
|
||||
const [prefilledFromCarry, setPrefilledFromCarry] = useState(false);
|
||||
|
||||
// Wenn Carryover-ToDos geladen sind und das Formular noch leer ist, vorbefüllen.
|
||||
if (
|
||||
!prefilledFromCarry &&
|
||||
carryQuery.data &&
|
||||
carryQuery.data.length > 0 &&
|
||||
todos.length === 1 &&
|
||||
!todos[0]!.title
|
||||
) {
|
||||
setPrefilledFromCarry(true);
|
||||
setTodos([...carryQuery.data.map((c) => ({ title: c.title, carried: true })), { title: '' }]);
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.dayEntry.checkIn.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
const msg =
|
||||
result.carriedOver > 0
|
||||
? `Locked in. ${result.carriedOver} offene ToDos vom Vortag übernommen.`
|
||||
: voice.toast.checkedIn;
|
||||
toast(msg);
|
||||
await utils.dayEntry.today.invalidate();
|
||||
await utils.dayEntry.pendingCarryOver.invalidate();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
function updateAt(i: number, value: string) {
|
||||
setTodos((arr) => arr.map((t, idx) => (idx === i ? { ...t, title: value } : t)));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setTodos((arr) => [...arr, { title: '' }]);
|
||||
}
|
||||
|
||||
function removeAt(i: number) {
|
||||
setTodos((arr) => arr.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function handleKey(e: React.KeyboardEvent<HTMLInputElement>, i: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (i === todos.length - 1 && todos[i]?.title.trim()) {
|
||||
addRow();
|
||||
// Fokus auf neue Zeile in nächstem Tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-todo-input]');
|
||||
inputs[i + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const filtered = todos.filter((t) => t.title.trim().length > 0);
|
||||
if (filtered.length === 0) {
|
||||
toast('Mindestens eine ToDo brauchst du.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({ todos: filtered.map((t) => ({ title: t.title.trim() })) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Heute</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
|
||||
</header>
|
||||
<Card.Root>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card.Body className="space-y-3">
|
||||
<p className="text-sm font-medium">Was nimmst du dir heute vor?</p>
|
||||
{carryQuery.data && carryQuery.data.length > 0 && (
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
<RotateCw size={12} className="inline -mt-0.5 mr-1" />
|
||||
{carryQuery.data.length} offene ToDos vom Vortag wurden für dich vorgemerkt.
|
||||
Behalte, ändere oder lösche sie.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{todos.map((t, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
{t.carried && (
|
||||
<span
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-[var(--color-ink-muted)]"
|
||||
title="vom Vortag übernommen"
|
||||
aria-label="vom Vortag übernommen"
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={t.title}
|
||||
onChange={(e) => updateAt(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKey(e, i)}
|
||||
placeholder="ToDo …"
|
||||
maxLength={200}
|
||||
data-todo-input
|
||||
ref={(el) => {
|
||||
if (i === 0 && el && el.value === '') el.focus();
|
||||
}}
|
||||
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
{todos.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
aria-label="ToDo entfernen"
|
||||
className="rounded-[var(--radius-md)] p-2 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Plus size={14} /> Weitere
|
||||
</button>
|
||||
</Card.Body>
|
||||
<Card.Footer className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{voice.cta.checkIn}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EinstempeltView({
|
||||
todos,
|
||||
entry,
|
||||
}: {
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
|
||||
entry: { status: 'begun' | 'full' | 'absent' };
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Heute</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">
|
||||
{entry.status === 'full'
|
||||
? 'Tag abgeschlossen.'
|
||||
: 'Locked in. Heute Abend reflektieren.'}
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{todos.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
t.status === 'done'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ink-faint)]'
|
||||
: 'h-2 w-2 rounded-full bg-[var(--color-border-strong)]'
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
t.status === 'dropped' ? 'line-through text-[var(--color-ink-muted)]' : ''
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
{entry.status !== 'full' && (
|
||||
<Card.Footer className="flex justify-end">
|
||||
<Button onClick={() => (location.href = '/reflexion')}>{voice.cta.reflect}</Button>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { ToolCtrlLogo } from '../components/ui/ToolCtrlLogo';
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
session_expired: 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.',
|
||||
oidc_failed: 'Anmeldung über Authentik ist fehlgeschlagen. Bitte erneut versuchen.',
|
||||
};
|
||||
|
||||
export function LoginRoute() {
|
||||
const [params] = useSearchParams();
|
||||
const error = params.get('error');
|
||||
|
||||
// Dev: automatisch in den Dev-Login-Endpoint redirecten — kein Klick nötig.
|
||||
// Authentik kommt später in der Roadmap (siehe docs/operations.md).
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && !error) {
|
||||
window.location.replace('/api/auth/dev-login');
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[var(--color-bg)] px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h1 className="inline-flex items-center gap-2 text-4xl font-bold">
|
||||
everythingbutwork <ToolCtrlLogo size={32} />
|
||||
</h1>
|
||||
<p className="mt-3 text-[var(--color-ink-muted)]">
|
||||
Internes ToolCTRL-Projektmanagement.
|
||||
</p>
|
||||
{error && (
|
||||
<p
|
||||
className="mt-4 rounded-[var(--radius-md)] border border-[var(--color-ampel-red-bg)] bg-[var(--color-ampel-red-bg)] px-4 py-2 text-sm text-[var(--color-ampel-red-fg)]"
|
||||
role="alert"
|
||||
>
|
||||
{ERROR_MESSAGES[error] ?? 'Anmeldung fehlgeschlagen.'}
|
||||
</p>
|
||||
)}
|
||||
{!import.meta.env.DEV && (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-6 inline-flex h-11 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-6 text-base font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Über Authentik anmelden
|
||||
</a>
|
||||
)}
|
||||
{import.meta.env.DEV && (
|
||||
<p className="mt-4 text-xs text-[var(--color-ink-muted)]">
|
||||
Dev-Modus: Auto-Login wird vorgenommen…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export function NotFoundRoute() {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold">Nicht gefunden.</h1>
|
||||
<p className="mt-2 text-[var(--color-ink-muted)]">
|
||||
Diese Seite gibt es nicht. Vielleicht meintest du den Puls?
|
||||
</p>
|
||||
<Link
|
||||
to="/puls"
|
||||
className="mt-6 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Zum Puls
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { useCurrentUser } from '../hooks/useCurrentUser';
|
||||
import { voice } from '../lib/voice';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function ProjektRoute() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const q = trpc.project.byId.useQuery({ id });
|
||||
const [abschlussOpen, setAbschlussOpen] = useState(false);
|
||||
if (q.isPending) return <SkeletonCard lines={6} />;
|
||||
if (!q.data) return null;
|
||||
const { project: p, area: a, milestones } = q.data;
|
||||
const isP1 = me?.id === p.verantwortlicheUserId;
|
||||
const pflichtKeys: Array<{ key: string; label: string }> = [
|
||||
{ key: 'steckbrief_freigegeben', label: 'Steckbrief in Outline freigegeben' },
|
||||
{ key: 'verantwortlicher', label: 'Projektverantwortliche/r gesetzt' },
|
||||
{ key: 'plane_board', label: 'Plane-Board verlinkt' },
|
||||
{ key: 'meilensteine', label: 'Meilensteine vorhanden' },
|
||||
{ key: 'status_aktuell', label: 'Status-Ampel aktuell (≤ 7 Werktage)' },
|
||||
{ key: 'retrospektive', label: 'Abschluss-Retrospektive (bei beendeten Projekten)' },
|
||||
];
|
||||
|
||||
function goBack() {
|
||||
// Wenn der History-Stack einen Vorgänger hat, dorthin; sonst auf den Bereich.
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate(`/bereich/${a.slug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label="Zurück"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
<Link to="/puls" className="hover:underline">
|
||||
Puls
|
||||
</Link>
|
||||
{' › '}
|
||||
<Link to={`/bereich/${a.slug}`} className="hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold">{p.name}</h1>
|
||||
{isP1 && !p.archiviert && (
|
||||
<Button variant="secondary" onClick={() => setAbschlussOpen(true)}>
|
||||
Projekt abschließen
|
||||
</Button>
|
||||
)}
|
||||
{p.archiviert && (
|
||||
<span className="rounded-full bg-black/5 px-2.5 py-1 text-xs">archiviert</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
{p.kind === 'kundenprojekt' ? 'Kundenprojekt' : 'Internes Projekt'} ·{' '}
|
||||
{p.arbeitsmodus ?? '—'} · {p.deadline ? `Deadline ${p.deadline}` : 'kein Deadline'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{abschlussOpen && (
|
||||
<AbschlussDialog projectId={p.id} onClose={() => setAbschlussOpen(false)} />
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Verlinkungen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body className="space-y-2 text-sm">
|
||||
<LinkRow label="Outline-Steckbrief" url={p.outlineSteckbriefUrl} />
|
||||
<LinkRow label="Plane-Board" url={p.planeProjectUrl} />
|
||||
<LinkRow label="Risikoliste" url={p.risikolisteUrl} />
|
||||
<LinkRow label="Lessons Learned" url={p.lessonsLearnedUrl} />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Pflicht-Minimum</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{pflichtKeys.map(({ key, label }) => {
|
||||
const ok = p.pflichtMinimumState[key] === true;
|
||||
return (
|
||||
<li key={key} className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block h-2.5 w-2.5 rounded-full',
|
||||
ok ? 'bg-[var(--color-ampel-green-fg)]' : 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span className={ok ? '' : 'text-[var(--color-ink-muted)]'}>{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-lg font-semibold">Meilensteine</h2>
|
||||
{milestones.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Meilensteine.</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{milestones.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
m.status === 'done' && 'line-through text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{m.title}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
{m.due ?? '—'} · {m.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{p.dodText && (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Definition of Done</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p className="whitespace-pre-wrap text-sm">{p.dodText}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: () => void }) {
|
||||
const [url, setUrl] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.project.archive.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.projectArchived);
|
||||
await utils.project.byId.invalidate({ id: projectId });
|
||||
await utils.project.listArchived.invalidate();
|
||||
await utils.puls.overview.invalidate();
|
||||
navigate('/archiv');
|
||||
},
|
||||
});
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ projectId, lessonsLearnedUrl: url });
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Projekt abschließen</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
|
||||
Lessons-Learned-URL ist Pflicht (Leitfaden §9 Punkt 6).
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://outline.tool-ctrl.de/lessons-learned/..."
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Abschließen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ label, url }: { label: string; url: string | null }) {
|
||||
return (
|
||||
<p className="flex items-center justify-between">
|
||||
<span className="text-[var(--color-ink-muted)]">{label}</span>
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="truncate hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--color-ink-faint)]">—</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Link } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red' | 'grey';
|
||||
|
||||
export function PulsRoute() {
|
||||
const q = trpc.puls.overview.useQuery();
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
|
||||
if (q.isPending) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!q.data) return null;
|
||||
|
||||
const isCheckedIn =
|
||||
today.data?.entry?.status !== undefined && today.data.entry.status !== 'absent';
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Unternehmens-Puls</h1>
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
Stand: {formatDate(q.data.date)} · Jourfixe {q.data.jourfixe.label}
|
||||
</p>
|
||||
</div>
|
||||
{!isCheckedIn && (
|
||||
<Link
|
||||
to="/heute"
|
||||
className="inline-flex h-11 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-5 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
{voice.hint.notCheckedInToday} → {voice.cta.checkIn}
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="bereiche-heading">
|
||||
<h2 id="bereiche-heading" className="sr-only">
|
||||
Bereiche
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{q.data.areas.map((a) => (
|
||||
<Link key={a.id} to={`/bereich/${a.slug}`} className="contents">
|
||||
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)] transition-shadow">
|
||||
<Card.Body className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{a.name}</h3>
|
||||
<AmpelDot ampel={a.ampel as Ampel} />
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-[var(--color-ink-muted)]">
|
||||
<span>{a.counts.green} grün</span>
|
||||
<span>{a.counts.amber} gelb</span>
|
||||
<span>{a.counts.red} rot</span>
|
||||
{a.counts.grey > 0 && <span>{a.counts.grey} ohne Status</span>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Heute</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<p className="text-2xl font-bold">
|
||||
{q.data.heuteQuote.gepingt} / {q.data.heuteQuote.gesamt}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Locked in</p>
|
||||
{q.data.heuteQuote.fehlende.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]">
|
||||
{q.data.heuteQuote.fehlende.length} fehlt heute noch
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 text-sm">
|
||||
{q.data.heuteQuote.fehlende.map((u) => (
|
||||
<li key={u.id}>{u.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root className="md:col-span-2">
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Bereichsübergreifend offen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{q.data.blockers.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.blockers}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{q.data.blockers.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{b.title}</span>
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
seit {formatDate(b.openedAt.toString())}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
|
||||
{q.data.pflichtMinimumOffen.length > 0 && (
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Pflicht-Minimum offen</h2>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{q.data.pflichtMinimumOffen.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link to={`/projekt/${p.id}`} className="hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
<TeamHeuteSection />
|
||||
|
||||
<section aria-labelledby="feed-heading">
|
||||
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
|
||||
Gestern im Team
|
||||
</h2>
|
||||
{q.data.feed.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.feed}</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{q.data.feed.map((entry) => (
|
||||
<Card.Root key={entry.id}>
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{entry.userName}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">{entry.date}</p>
|
||||
</div>
|
||||
{entry.ampel && <AmpelBadge ampel={entry.ampel} />}
|
||||
</div>
|
||||
{entry.reflectionText && <p className="mt-2 text-sm">{entry.reflectionText}</p>}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AmpelDot({ ampel }: { ampel: Ampel }) {
|
||||
const colorClass = {
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
grey: 'bg-[var(--color-ink-faint)]',
|
||||
}[ampel];
|
||||
return <span className={clsx('inline-block h-3 w-3 rounded-full', colorClass)} aria-hidden />;
|
||||
}
|
||||
|
||||
function AmpelBadge({ ampel }: { ampel: 'green' | 'amber' | 'red' }) {
|
||||
const cls = {
|
||||
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
|
||||
}[ampel];
|
||||
const label = { green: 'Im Plan', amber: 'Gefährdet', red: 'Blockiert' }[ampel];
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
cls,
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamHeuteSection() {
|
||||
const team = trpc.dayEntry.todayTeam.useQuery();
|
||||
const users = trpc.user.list.useQuery();
|
||||
if (team.isPending) return null;
|
||||
const data = team.data ?? [];
|
||||
const userMap = new Map((users.data ?? []).map((u) => [u.id, u.name]));
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Heute hat sich noch niemand locked in.
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((u) => (
|
||||
<Card.Root key={u.userId}>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{userMap.get(u.userId) ?? '—'}</p>
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
u.entryStatus === 'full'
|
||||
? 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]'
|
||||
: u.entryStatus === 'begun'
|
||||
? 'bg-black/5 text-[var(--color-ink-muted)]'
|
||||
: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{u.entryStatus === 'full'
|
||||
? 'reflektiert'
|
||||
: u.entryStatus === 'begun'
|
||||
? 'locked in'
|
||||
: 'abwesend'}
|
||||
</span>
|
||||
</div>
|
||||
{u.todos.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-[var(--color-ink-faint)]">Keine ToDos eingetragen.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5 text-sm">
|
||||
{u.todos.map((t) => (
|
||||
<li key={t.id} className="flex items-start gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-1 inline-block h-2 w-2 rounded-full shrink-0',
|
||||
t.status === 'done'
|
||||
? 'bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'bg-[var(--color-ink-faint)]'
|
||||
: 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
t.status === 'dropped' && 'line-through text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(input: string | Date): string {
|
||||
const dt = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(dt.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(dt);
|
||||
}
|
||||
|
||||
// Button als referenz behalten (Linter)
|
||||
void Button;
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
import { Check, X, Minus, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red';
|
||||
type Trend = 'besser' | 'gleich' | 'schlechter';
|
||||
type TodoStatus = 'open' | 'done' | 'dropped';
|
||||
|
||||
export function ReflexionRoute() {
|
||||
const today = trpc.dayEntry.today.useQuery();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (today.isPending) return <SkeletonCard lines={6} />;
|
||||
if (!today.data?.entry) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
Du bist heute noch nicht locked in. {voice.hint.notCheckedInToday}
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => navigate('/heute')}>
|
||||
Zum Lock-in
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ReflectForm
|
||||
entry={today.data.entry}
|
||||
todos={today.data.todos}
|
||||
onDone={() => navigate('/puls')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReflectForm({
|
||||
entry,
|
||||
todos,
|
||||
onDone,
|
||||
}: {
|
||||
entry: { id: string };
|
||||
todos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: TodoStatus;
|
||||
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
||||
}>;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [todoStates, setTodoStates] = useState<
|
||||
Record<string, { status: TodoStatus; reason?: string }>
|
||||
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
|
||||
const [reflectionText, setReflectionText] = useState('');
|
||||
const [ampel, setAmpel] = useState<Ampel | null>(null);
|
||||
const [trend, setTrend] = useState<Trend | null>(null);
|
||||
const [blockerEnabled, setBlockerEnabled] = useState(false);
|
||||
const [blockerTitle, setBlockerTitle] = useState('');
|
||||
const [blockerDescription, setBlockerDescription] = useState('');
|
||||
|
||||
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.dayEntry.submitReflection.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.reflected);
|
||||
await utils.dayEntry.today.invalidate();
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
function setTodoStatus(id: string, status: TodoStatus) {
|
||||
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ampel) {
|
||||
toast('Bitte Ampel setzen.');
|
||||
return;
|
||||
}
|
||||
if (hasProjectBezug && !trend) {
|
||||
toast('Bei Projektbezug ist der Trend Pflicht.');
|
||||
return;
|
||||
}
|
||||
if (blockerEnabled && !blockerTitle.trim()) {
|
||||
toast('Blocker braucht einen Titel.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
dayEntryId: entry.id,
|
||||
todos: Object.entries(todoStates).map(([id, s]) => ({
|
||||
id,
|
||||
status: s.status,
|
||||
dropReason: s.reason,
|
||||
})),
|
||||
reflectionText,
|
||||
ampel,
|
||||
trend: trend ?? null,
|
||||
...(blockerEnabled
|
||||
? { blocker: { title: blockerTitle, description: blockerDescription || undefined } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Reflexion</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 className="text-lg font-semibold">Was ist heute passiert?</h2>
|
||||
</Card.Header>
|
||||
<Card.Body className="space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--color-ink-muted)]">
|
||||
Deine ToDos vom Morgen
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{todos.map((t) => {
|
||||
const status = todoStates[t.id]?.status ?? 'open';
|
||||
return (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm">{t.title}</span>
|
||||
<div className="flex gap-1">
|
||||
<StatusBtn
|
||||
active={status === 'done'}
|
||||
onClick={() => setTodoStatus(t.id, 'done')}
|
||||
label="Erledigt"
|
||||
>
|
||||
<Check size={14} />
|
||||
</StatusBtn>
|
||||
<StatusBtn
|
||||
active={status === 'open'}
|
||||
onClick={() => setTodoStatus(t.id, 'open')}
|
||||
label="Offen"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</StatusBtn>
|
||||
<StatusBtn
|
||||
active={status === 'dropped'}
|
||||
onClick={() => setTodoStatus(t.id, 'dropped')}
|
||||
label="Verworfen"
|
||||
>
|
||||
<X size={14} />
|
||||
</StatusBtn>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-3">
|
||||
<label className="block text-sm font-medium" htmlFor="reflection">
|
||||
Sonst noch was?
|
||||
</label>
|
||||
<textarea
|
||||
id="reflection"
|
||||
value={reflectionText}
|
||||
onChange={(e) => setReflectionText(e.target.value)}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-ink-faint)] text-right">
|
||||
{reflectionText.length} / 1000
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-4">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">Ampel</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<AmpelBtn
|
||||
value="green"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.green}
|
||||
/>
|
||||
<AmpelBtn
|
||||
value="amber"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.amber}
|
||||
/>
|
||||
<AmpelBtn
|
||||
value="red"
|
||||
current={ampel}
|
||||
onChange={setAmpel}
|
||||
label={voice.label.ampel.red}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
Trend {hasProjectBezug && <span aria-hidden>*</span>}
|
||||
<span className="ml-1 text-xs text-[var(--color-ink-faint)]">
|
||||
{hasProjectBezug ? '(Pflicht bei Projektbezug)' : '(optional)'}
|
||||
</span>
|
||||
</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<TrendBtn value="besser" current={trend} onChange={setTrend}>
|
||||
<TrendingUp size={14} /> Besser
|
||||
</TrendBtn>
|
||||
<TrendBtn value="gleich" current={trend} onChange={setTrend}>
|
||||
<ArrowRight size={14} /> Gleich
|
||||
</TrendBtn>
|
||||
<TrendBtn value="schlechter" current={trend} onChange={setTrend}>
|
||||
<TrendingDown size={14} /> Schlechter
|
||||
</TrendBtn>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blockerEnabled}
|
||||
onChange={(e) => setBlockerEnabled(e.target.checked)}
|
||||
/>
|
||||
Blocker?
|
||||
</label>
|
||||
{blockerEnabled && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={blockerTitle}
|
||||
onChange={(e) => setBlockerTitle(e.target.value)}
|
||||
placeholder="Was klemmt?"
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={blockerDescription}
|
||||
onChange={(e) => setBlockerDescription(e.target.value)}
|
||||
placeholder="Mehr Kontext (optional)"
|
||||
rows={2}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{voice.cta.reflect}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBtn({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border',
|
||||
active
|
||||
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AmpelBtn({
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: Ampel;
|
||||
current: Ampel | null;
|
||||
onChange: (a: Ampel) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const bgMap: Record<Ampel, string> = {
|
||||
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
|
||||
};
|
||||
const dotMap: Record<Ampel, string> = {
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
};
|
||||
const active = current === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={clsx(
|
||||
'flex h-12 items-center gap-2 rounded-[var(--radius-md)] px-3 text-sm font-medium border',
|
||||
active
|
||||
? bgMap[value] + ' border-[var(--color-ink)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
<span className={clsx('h-2.5 w-2.5 rounded-full', dotMap[value])} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBtn({
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
value: Trend;
|
||||
current: Trend | null;
|
||||
onChange: (t: Trend) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = current === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={clsx(
|
||||
'flex h-10 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border px-3 text-sm font-medium',
|
||||
active
|
||||
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ------ Brand Tokens (UX-Spec § Visual Design Foundation) ------- */
|
||||
|
||||
@theme {
|
||||
/* Neutrals */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #fafafa;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-strong: #d4d4d4;
|
||||
--color-ink: #0a0a0a;
|
||||
--color-ink-muted: #525252;
|
||||
--color-ink-faint: #a3a3a3;
|
||||
--color-brand: #0a0a0a;
|
||||
--color-brand-fg: #ffffff;
|
||||
|
||||
/* Ampel */
|
||||
--color-ampel-green-fg: #15803d;
|
||||
--color-ampel-green-bg: #dcfce7;
|
||||
--color-ampel-amber-fg: #a16207;
|
||||
--color-ampel-amber-bg: #fef3c7;
|
||||
--color-ampel-red-fg: #b91c1c;
|
||||
--color-ampel-red-bg: #fee2e2;
|
||||
|
||||
/* Trend */
|
||||
--color-trend-up: #15803d;
|
||||
--color-trend-flat: #525252;
|
||||
--color-trend-down: #b91c1c;
|
||||
|
||||
/* Typografie */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(10, 10, 10, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(10, 10, 10, 0.06);
|
||||
--shadow-lg: 0 12px 32px rgba(10, 10, 10, 0.1);
|
||||
}
|
||||
|
||||
/* ------ Base styles ------- */
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-ink);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reusable utility classes (NFR-9 — ≤ 3 Klicks Workflow). */
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-brand-fg);
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 7173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:7100',
|
||||
'/trpc': 'http://localhost:7100',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user