chore(format): Prettier über das gesamte Repo
Behebt die rote CI (format:check schlug an 36 Alt-Bestand-Dateien fehl: migrations/meta-Snapshots, seed.ts, schemas/lead.ts, README u. a.) und erzwingt nebenbei einen frischen web-Build beim Auto-Deploy (apps/web-Dateien geaendert -> COPY-Layer invalidiert -> Vite baut neu). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b340b45110
commit
a1cf6f3883
36 files changed
+1559
-3510
No files matched your search
@@ -111,13 +111,18 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 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 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 found = (
|
||||
await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1)
|
||||
)[0];
|
||||
const userRow = found
|
||||
? found
|
||||
: await upsertUserFromOidc({
|
||||
|
||||
@@ -47,7 +47,10 @@ async function safeText(res: Response): Promise<string> {
|
||||
* Startet ein laufendes Timesheet in Kimai (Clock-In). Liefert die Kimai-
|
||||
* Timesheet-ID zurück oder null (nicht konfiguriert / Fehler).
|
||||
*/
|
||||
export async function kimaiClockIn(opts: { begin: Date; description?: string }): Promise<number | null> {
|
||||
export async function kimaiClockIn(opts: {
|
||||
begin: Date;
|
||||
description?: string;
|
||||
}): Promise<number | null> {
|
||||
const cfg = kimaiConfig();
|
||||
if (!cfg) {
|
||||
logger.debug('kimai not configured — clock-in skipped');
|
||||
|
||||
@@ -41,7 +41,8 @@ export const changeRouter = router({
|
||||
const existing = (
|
||||
await ctx.db.select().from(changeEntry).where(eq(changeEntry.id, id)).limit(1)
|
||||
)[0];
|
||||
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
if (!existing)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
return existing;
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { and, eq, ne, isNull, isNotNull, lt, count, asc, desc } from 'drizzle-orm';
|
||||
import { area, project, blocker, dayEntry, user, risk, milestone, type Area, type Project } from '@cockpit/db';
|
||||
import {
|
||||
area,
|
||||
project,
|
||||
blocker,
|
||||
dayEntry,
|
||||
user,
|
||||
risk,
|
||||
milestone,
|
||||
type Area,
|
||||
type Project,
|
||||
} from '@cockpit/db';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, previousWerktag } from '../lib/werktag.js';
|
||||
import { aggregateAreaAmpel } from '../lib/ampel.js';
|
||||
@@ -17,73 +27,73 @@ export const dashboardRouter = router({
|
||||
showstopperRows,
|
||||
milestoneRows,
|
||||
] = await Promise.all([
|
||||
ctx.db.select().from(area).orderBy(area.order),
|
||||
ctx.db.select().from(project).where(eq(project.archiviert, false)),
|
||||
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
|
||||
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))
|
||||
// „Gestern im Team": volle Reflexionen vor heute (heutige bleiben privat
|
||||
// bis morgen). Unabhängig vom publish-Cron.
|
||||
.where(and(lt(dayEntry.date, todayDate), eq(dayEntry.status, 'full')))
|
||||
.orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt))
|
||||
.limit(50),
|
||||
ctx.db
|
||||
.select({
|
||||
id: risk.id,
|
||||
title: risk.title,
|
||||
projectId: risk.projectId,
|
||||
projectName: project.name,
|
||||
})
|
||||
.from(risk)
|
||||
.innerJoin(project, eq(project.id, risk.projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(risk.showstopper, true),
|
||||
eq(risk.kind, 'risiko'),
|
||||
ne(risk.status, 'geschlossen'),
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
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))
|
||||
// „Gestern im Team": volle Reflexionen vor heute (heutige bleiben privat
|
||||
// bis morgen). Unabhängig vom publish-Cron.
|
||||
.where(and(lt(dayEntry.date, todayDate), eq(dayEntry.status, 'full')))
|
||||
.orderBy(desc(dayEntry.date), desc(dayEntry.reflectedAt))
|
||||
.limit(50),
|
||||
ctx.db
|
||||
.select({
|
||||
id: risk.id,
|
||||
title: risk.title,
|
||||
projectId: risk.projectId,
|
||||
projectName: project.name,
|
||||
})
|
||||
.from(risk)
|
||||
.innerJoin(project, eq(project.id, risk.projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(risk.showstopper, true),
|
||||
eq(risk.kind, 'risiko'),
|
||||
ne(risk.status, 'geschlossen'),
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
// Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend.
|
||||
ctx.db
|
||||
.select({
|
||||
id: milestone.id,
|
||||
title: milestone.title,
|
||||
due: milestone.due,
|
||||
status: milestone.status,
|
||||
projectId: milestone.projectId,
|
||||
projectName: project.name,
|
||||
projectLogoUrl: project.logoUrl,
|
||||
areaId: project.areaId,
|
||||
})
|
||||
.from(milestone)
|
||||
.innerJoin(project, eq(project.id, milestone.projectId))
|
||||
.where(
|
||||
and(
|
||||
ne(milestone.status, 'done'),
|
||||
isNotNull(milestone.due),
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(milestone.due))
|
||||
.limit(15),
|
||||
]);
|
||||
),
|
||||
// Anstehende Meilensteine (offen/überfällig, mit Datum) projektübergreifend.
|
||||
ctx.db
|
||||
.select({
|
||||
id: milestone.id,
|
||||
title: milestone.title,
|
||||
due: milestone.due,
|
||||
status: milestone.status,
|
||||
projectId: milestone.projectId,
|
||||
projectName: project.name,
|
||||
projectLogoUrl: project.logoUrl,
|
||||
areaId: project.areaId,
|
||||
})
|
||||
.from(milestone)
|
||||
.innerJoin(project, eq(project.id, milestone.projectId))
|
||||
.where(
|
||||
and(
|
||||
ne(milestone.status, 'done'),
|
||||
isNotNull(milestone.due),
|
||||
eq(project.archiviert, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(milestone.due))
|
||||
.limit(15),
|
||||
]);
|
||||
|
||||
const projectsByArea = new Map<string, Project[]>();
|
||||
for (const p of allProjects) {
|
||||
|
||||
@@ -65,8 +65,7 @@ export const dealRouter = router({
|
||||
enteredStageAt: new Date(),
|
||||
wonAt: input.stage === 'abschluss' ? new Date() : null,
|
||||
// Gewonnener Deal = voller Auftragswert (nicht mit Wahrscheinlichkeit gewichtet).
|
||||
wonValueEur:
|
||||
input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null,
|
||||
wonValueEur: input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null,
|
||||
})
|
||||
.where(eq(deal.id, input.dealId))
|
||||
.returning();
|
||||
|
||||
@@ -15,51 +15,45 @@ export const riskRouter = router({
|
||||
.orderBy(desc(risk.showstopper), desc(risk.createdAt));
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(RiskSchemas.createRiskInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [created] = await ctx.db
|
||||
.insert(risk)
|
||||
.values({
|
||||
projectId: input.projectId,
|
||||
kind: input.kind,
|
||||
title: input.title,
|
||||
wahrscheinlichkeit: input.wahrscheinlichkeit,
|
||||
auswirkung: input.auswirkung,
|
||||
herkunft: input.herkunft ?? null,
|
||||
showstopper: input.showstopper,
|
||||
massnahme: input.massnahme ?? null,
|
||||
strategie: input.strategie ?? null,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
status: input.status,
|
||||
createdBy: ctx.user.id,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
create: protectedProcedure.input(RiskSchemas.createRiskInput).mutation(async ({ ctx, input }) => {
|
||||
const [created] = await ctx.db
|
||||
.insert(risk)
|
||||
.values({
|
||||
projectId: input.projectId,
|
||||
kind: input.kind,
|
||||
title: input.title,
|
||||
wahrscheinlichkeit: input.wahrscheinlichkeit,
|
||||
auswirkung: input.auswirkung,
|
||||
herkunft: input.herkunft ?? null,
|
||||
showstopper: input.showstopper,
|
||||
massnahme: input.massnahme ?? null,
|
||||
strategie: input.strategie ?? null,
|
||||
ownerUserId: input.ownerUserId ?? null,
|
||||
status: input.status,
|
||||
createdBy: ctx.user.id,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(RiskSchemas.updateRiskInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...patch } = input;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
const existing = (await ctx.db.select().from(risk).where(eq(risk.id, id)).limit(1))[0];
|
||||
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
return existing;
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(risk)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(risk.id, id))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
return updated;
|
||||
}),
|
||||
update: protectedProcedure.input(RiskSchemas.updateRiskInput).mutation(async ({ ctx, input }) => {
|
||||
const { id, ...patch } = input;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
const existing = (await ctx.db.select().from(risk).where(eq(risk.id, id)).limit(1))[0];
|
||||
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
return existing;
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(risk)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(risk.id, id))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
return updated;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(RiskSchemas.removeRiskInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(risk).where(eq(risk.id, input.id));
|
||||
return { ok: true as const };
|
||||
}),
|
||||
remove: protectedProcedure.input(RiskSchemas.removeRiskInput).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(risk).where(eq(risk.id, input.id));
|
||||
return { ok: true as const };
|
||||
}),
|
||||
});
|
||||
@@ -50,7 +50,8 @@ export const stakeholderRouter = router({
|
||||
const existing = (
|
||||
await ctx.db.select().from(stakeholder).where(eq(stakeholder.id, id)).limit(1)
|
||||
)[0];
|
||||
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
|
||||
if (!existing)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
|
||||
return existing;
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
@@ -58,7 +59,8 @@ export const stakeholderRouter = router({
|
||||
.set(patch)
|
||||
.where(eq(stakeholder.id, id))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
|
||||
if (!updated)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
|
||||
return updated;
|
||||
}),
|
||||
|
||||
|
||||
@@ -120,7 +120,9 @@ export function LogoUpload({
|
||||
{error ? (
|
||||
<span className="text-xs text-[var(--color-ampel-red-fg)]">{error}</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">Quadratisch, wird auf 256px verkleinert.</span>
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">
|
||||
Quadratisch, wird auf 256px verkleinert.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -85,7 +85,9 @@ export function TeamTimeSection() {
|
||||
title="Über parallele Aufgaben getrackte Summe (zählt überlappende Zeit mehrfach)."
|
||||
>
|
||||
{' · getrackt '}
|
||||
<span className="font-mono">{formatDurationCompact(it.trackedSeconds)}</span>
|
||||
<span className="font-mono">
|
||||
{formatDurationCompact(it.trackedSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -131,18 +133,10 @@ function ActiveLine({
|
||||
}
|
||||
|
||||
function IdleLine() {
|
||||
return (
|
||||
<p className="mt-3 text-xs text-[var(--color-ink-faint)]">Aktuell kein Timer.</p>
|
||||
);
|
||||
return <p className="mt-3 text-xs text-[var(--color-ink-faint)]">Aktuell kein Timer.</p>;
|
||||
}
|
||||
|
||||
function Breakdown({
|
||||
byCategory,
|
||||
total,
|
||||
}: {
|
||||
byCategory: Record<string, number>;
|
||||
total: number;
|
||||
}) {
|
||||
function Breakdown({ byCategory, total }: { byCategory: Record<string, number>; total: number }) {
|
||||
if (total === 0) return null;
|
||||
const ordered = TIME_CATEGORIES.map((c) => ({
|
||||
key: c.value,
|
||||
|
||||
@@ -243,7 +243,9 @@ function QuickPicker({
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() => onStart({ category: c.value, description: description.trim() || undefined })}
|
||||
onClick={() =>
|
||||
onStart({ category: c.value, description: description.trim() || undefined })
|
||||
}
|
||||
disabled={pending}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-sm hover:bg-black/5"
|
||||
>
|
||||
|
||||
@@ -5,10 +5,7 @@ function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
// Scharfe Kanten, Haarlinien-Rahmen ohne Schatten — ToolCTRL-Website-Stil.
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)]',
|
||||
className,
|
||||
)}
|
||||
className={clsx('bg-[var(--color-surface)] border border-[var(--color-border)]', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
export type LeadStatus = 'cold' | 'warm' | 'hot' | 'won' | 'lost';
|
||||
|
||||
export const LEAD_STATUS_META: Record<
|
||||
LeadStatus,
|
||||
{ label: string; badge: string; dot: string }
|
||||
> = {
|
||||
export const LEAD_STATUS_META: Record<LeadStatus, { label: string; badge: string; dot: string }> = {
|
||||
cold: {
|
||||
label: 'Cold',
|
||||
badge: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
|
||||
@@ -29,8 +29,14 @@ export const MODI: ProjectModus[] = ['relax', 'standard', 'eskalation', 'taskfor
|
||||
export type Level = 'niedrig' | 'mittel' | 'hoch';
|
||||
|
||||
export const LEVEL_META: Record<Level, { label: string; badge: string }> = {
|
||||
niedrig: { label: 'Niedrig', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
|
||||
mittel: { label: 'Mittel', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
|
||||
niedrig: {
|
||||
label: 'Niedrig',
|
||||
badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
},
|
||||
mittel: {
|
||||
label: 'Mittel',
|
||||
badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
},
|
||||
hoch: { label: 'Hoch', badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]' },
|
||||
};
|
||||
export const LEVELS: Level[] = ['niedrig', 'mittel', 'hoch'];
|
||||
@@ -66,7 +72,10 @@ export type ProjectPhase = 'definition' | 'planung' | 'umsetzung' | 'abschluss';
|
||||
export const PHASES: ProjectPhase[] = ['definition', 'planung', 'umsetzung', 'abschluss'];
|
||||
export const PHASE_META: Record<ProjectPhase, { label: string; gateZiel: string }> = {
|
||||
definition: { label: 'Definition', gateZiel: 'Steckbrief abgestimmt und freigegeben' },
|
||||
planung: { label: 'Planung', gateZiel: 'Plan steht — Backlog zerlegt, Meilensteine & Risiken erfasst' },
|
||||
planung: {
|
||||
label: 'Planung',
|
||||
gateZiel: 'Plan steht — Backlog zerlegt, Meilensteine & Risiken erfasst',
|
||||
},
|
||||
umsetzung: { label: 'Umsetzung', gateZiel: 'Liefergegenstände abgenommen' },
|
||||
abschluss: { label: 'Abschluss', gateZiel: 'Projekt beendet, Erkenntnisse gesichert' },
|
||||
};
|
||||
@@ -86,12 +95,26 @@ export const RISK_STRATEGIE_LABEL: Record<RiskStrategie, string> = {
|
||||
mindern: 'Mindern',
|
||||
akzeptieren: 'Akzeptieren',
|
||||
};
|
||||
export const RISK_STRATEGIEN: RiskStrategie[] = ['vermeiden', 'verlagern', 'mindern', 'akzeptieren'];
|
||||
export const RISK_STRATEGIEN: RiskStrategie[] = [
|
||||
'vermeiden',
|
||||
'verlagern',
|
||||
'mindern',
|
||||
'akzeptieren',
|
||||
];
|
||||
|
||||
export type ChangeEntscheidung = 'offen' | 'genehmigt' | 'abgelehnt';
|
||||
export const CHANGE_ENTSCHEIDUNG_META: Record<ChangeEntscheidung, { label: string; badge: string }> = {
|
||||
offen: { label: 'Offen', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
|
||||
genehmigt: { label: 'Genehmigt', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
|
||||
export const CHANGE_ENTSCHEIDUNG_META: Record<
|
||||
ChangeEntscheidung,
|
||||
{ label: string; badge: string }
|
||||
> = {
|
||||
offen: {
|
||||
label: 'Offen',
|
||||
badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
||||
},
|
||||
genehmigt: {
|
||||
label: 'Genehmigt',
|
||||
badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
||||
},
|
||||
abgelehnt: { label: 'Abgelehnt', badge: 'bg-black/5 text-[var(--color-ink-faint)]' },
|
||||
};
|
||||
export const CHANGE_ENTSCHEIDUNGEN: ChangeEntscheidung[] = ['offen', 'genehmigt', 'abgelehnt'];
|
||||
|
||||
@@ -106,7 +106,9 @@ export function DashboardRoute() {
|
||||
{m.ueberfaellig && ' · überfällig'}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-faint)]">·</span>
|
||||
<span className="text-[var(--color-ink-muted)]">{relativeDays(m.due!, q.data.date)}</span>
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
{relativeDays(m.due!, q.data.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
@@ -209,7 +211,10 @@ export function DashboardRoute() {
|
||||
<ul className="space-y-1 text-sm">
|
||||
{q.data.pflichtMinimumOffen.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link to={`/projekt/${p.id}`} className="inline-flex items-center gap-2 hover:underline">
|
||||
<Link
|
||||
to={`/projekt/${p.id}`}
|
||||
className="inline-flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<ProjectLogo url={p.logoUrl} name={p.name} size={20} />
|
||||
{p.name}
|
||||
</Link>
|
||||
@@ -331,7 +336,9 @@ function TeamHeuteSection() {
|
||||
</span>
|
||||
</div>
|
||||
{u.todos.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-[var(--color-ink-faint)]">Keine ToDos eingetragen.</p>
|
||||
<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) => (
|
||||
|
||||
@@ -25,9 +25,7 @@ export function LoginRoute() {
|
||||
<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>
|
||||
<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)]"
|
||||
|
||||
@@ -114,7 +114,10 @@ function layout(entries: RawEntry[], nowMs: number): { bars: Bar[]; rows: number
|
||||
export function TeamViewRoute() {
|
||||
const [date, setDate] = useState(todayStr);
|
||||
const isToday = date === todayStr;
|
||||
const q = trpc.time.teamTimeline.useQuery({ date }, { refetchInterval: isToday ? 30_000 : false });
|
||||
const q = trpc.time.teamTimeline.useQuery(
|
||||
{ date },
|
||||
{ refetchInterval: isToday ? 30_000 : false },
|
||||
);
|
||||
|
||||
// 1-Sekunden-Tick nur heute (für laufende Balken).
|
||||
const [, setTick] = useState(0);
|
||||
@@ -148,7 +151,8 @@ export function TeamViewRoute() {
|
||||
let axisEnd = hasData ? ceilHourMs(Math.max(...allMs)) : axisStart + 2 * 3600_000;
|
||||
if (axisEnd - axisStart < 2 * 3600_000) axisEnd = axisStart + 2 * 3600_000;
|
||||
const range = axisEnd - axisStart;
|
||||
const pct = (ms: number) => ((Math.min(Math.max(ms, axisStart), axisEnd) - axisStart) / range) * 100;
|
||||
const pct = (ms: number) =>
|
||||
((Math.min(Math.max(ms, axisStart), axisEnd) - axisStart) / range) * 100;
|
||||
|
||||
const hours: number[] = [];
|
||||
for (let t = axisStart; t <= axisEnd; t += 3600_000) hours.push(t);
|
||||
@@ -228,7 +232,10 @@ export function TeamViewRoute() {
|
||||
const { bars, rows } = layout(lane.entries, nowMs);
|
||||
const laneHeight = rows * BAR_H + (rows - 1) * GAP;
|
||||
return (
|
||||
<div key={lane.userId} className="flex items-stretch border-t border-[var(--color-border)] py-1.5">
|
||||
<div
|
||||
key={lane.userId}
|
||||
className="flex items-stretch border-t border-[var(--color-border)] py-1.5"
|
||||
>
|
||||
<div className="flex w-40 shrink-0 flex-col justify-center pr-2">
|
||||
<span className="truncate text-sm font-medium">{lane.name}</span>
|
||||
<span className="text-[10px] text-[var(--color-ink-muted)]">
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url(/fonts/space-grotesk-latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
@@ -18,9 +18,9 @@
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url(/fonts/space-grotesk-latin-ext.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
||||
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
|
||||
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user