Projektlogo: quadratisches Logo hochladen & anzeigen
ci / quality (push) Failing after 42s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

Logo kann bei der Projekterstellung und nachträglich im Projekt-Header
hochgeladen werden. Client-seitiger Center-Crop auf 256px WebP, gespeichert
als Data-URL in neuer Spalte project.logo_url (Migration 0016). Anzeige in
der Bereichs-Projektliste (Card) und im Detail-Header, mit Initialen-
Platzhalter wenn kein Logo gesetzt ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 20:44:58 +02:00
1 parent 45a87711fb
commit 1206dc5e0f
9 files changed
+3652 -119

No files matched your search

+1
View File
@@ -71,6 +71,7 @@ export const projectRouter = router({
areaId: input.areaId,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
kind: input.kind,
verantwortlicheUserId: input.verantwortlicheUserId ?? null,
arbeitsmodus: input.arbeitsmodus ?? null,
@@ -0,0 +1,135 @@
import { useRef, useState } from 'react';
import { clsx } from 'clsx';
import { Upload, X } from 'lucide-react';
// Datei → zentriert quadratisch zugeschnittene, auf `size` verkleinerte WebP-Data-URL.
async function fileToSquareDataUrl(file: File, size = 256): Promise<string> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
reader.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('Bild konnte nicht geladen werden.'));
i.src = dataUrl;
});
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas nicht verfügbar.');
// Center-Crop (cover): kürzere Kante bestimmt das Quadrat.
const s = Math.min(img.width, img.height);
const sx = (img.width - s) / 2;
const sy = (img.height - s) / 2;
ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size);
return canvas.toDataURL('image/webp', 0.85);
}
export function ProjectLogo({
url,
name,
size = 40,
className,
}: {
url: string | null | undefined;
name: string;
size?: number;
className?: string;
}) {
if (url) {
return (
<img
src={url}
alt=""
className={clsx('shrink-0 rounded-[var(--radius-md)] object-cover', className)}
style={{ width: size, height: size }}
/>
);
}
const initials = name.trim().slice(0, 2).toUpperCase() || '–';
return (
<div
className={clsx(
'flex shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-black/5 font-semibold text-[var(--color-ink-muted)]',
className,
)}
style={{ width: size, height: size, fontSize: Math.round(size * 0.34) }}
aria-hidden
>
{initials}
</div>
);
}
export function LogoUpload({
value,
name,
onChange,
className,
}: {
value: string | null;
name: string;
onChange: (v: string | null) => void;
className?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(file: File | undefined) {
if (!file) return;
setError(null);
setBusy(true);
try {
onChange(await fileToSquareDataUrl(file));
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload fehlgeschlagen.');
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className={clsx('flex items-center gap-3', className)}>
<ProjectLogo url={value} name={name} size={56} />
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 py-1 text-xs hover:border-[var(--color-ink)] disabled:opacity-50"
>
<Upload size={13} /> {busy ? 'Lädt…' : value ? 'Ändern' : 'Logo hochladen'}
</button>
{value && (
<button
type="button"
onClick={() => onChange(null)}
className="inline-flex items-center gap-1 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ampel-red-fg)]"
>
<X size={13} /> Entfernen
</button>
)}
</div>
{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>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
</div>
);
}
+12 -2
View File
@@ -9,6 +9,7 @@ import { toast } from 'sonner';
import { clsx } from 'clsx';
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
import { MODUS_META, type ProjectModus } from '../lib/project-meta';
import { ProjectLogo, LogoUpload } from '../components/project/ProjectLogo';
const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [
{ slug: 'entwicklung', label: 'Entwicklung' },
@@ -85,6 +86,7 @@ function Section({
projects: Array<{
id: string;
name: string;
logoUrl: string | null;
statusCache: 'green' | 'amber' | 'red' | null;
deadline: string | null;
modus: ProjectModus;
@@ -105,8 +107,10 @@ function Section({
{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>
<Card.Body className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<ProjectLogo url={p.logoUrl} name={p.name} size={40} />
<div className="min-w-0">
<p className="flex items-center gap-2 font-medium">
{p.name}
{p.modus !== 'standard' && (
@@ -123,6 +127,7 @@ function Section({
<p className="text-xs text-[var(--color-ink-muted)]">
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
</p>
</div>
</div>
<PflichtMinimumIndicator state={p.pflichtMinimumState} />
</Card.Body>
@@ -175,6 +180,7 @@ function ProjectCreateDialog({
const defaultArea = areas.find((a) => a.slug === defaultSlug);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [areaId, setAreaId] = useState(defaultArea?.id ?? '');
// Areas werden asynchron geladen; sobald sie da sind und noch nichts gewählt
@@ -207,6 +213,7 @@ function ProjectCreateDialog({
areaId,
name,
description: description.trim() || undefined,
logoUrl: logoUrl ?? undefined,
kind,
arbeitsmodus,
outlineSteckbriefUrl: steckbrief,
@@ -243,6 +250,9 @@ function ProjectCreateDialog({
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Logo (optional)">
<LogoUpload value={logoUrl} name={name} onChange={setLogoUrl} />
</Field>
<Field label="Bereich">
<select
required
+63 -1
View File
@@ -12,6 +12,7 @@ import { voice } from '../lib/voice';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
import { ProjectLogo, LogoUpload } from '../components/project/ProjectLogo';
import {
MODUS_META,
MODI,
@@ -109,7 +110,10 @@ export function ProjektRoute() {
</p>
</div>
<div className="mt-2 flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold">{p.name}</h1>
<div className="flex items-center gap-3">
<HeaderLogo projectId={p.id} logoUrl={p.logoUrl} name={p.name} />
<h1 className="text-3xl font-bold">{p.name}</h1>
</div>
{isP1 && !p.archiviert && (
<Button variant="secondary" onClick={() => setAbschlussOpen(true)}>
Projekt abschließen
@@ -239,6 +243,64 @@ function ModusControl({ projectId, modus }: { projectId: string; modus: ProjectM
}
// --- Ziel & Maxime + SMART-Ziel, Nicht-Ziele, Liefergegenstände, Zieldreieck -
function HeaderLogo({
projectId,
logoUrl,
name,
}: {
projectId: string;
logoUrl: string | null;
name: string;
}) {
const utils = trpc.useUtils();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<string | null>(logoUrl);
const update = trpc.project.update.useMutation({
onSuccess: async () => {
toast(voice.toast.saved);
await utils.project.byId.invalidate({ id: projectId });
setEditing(false);
},
});
if (editing) {
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
<LogoUpload value={draft} name={name} onChange={setDraft} />
<div className="mt-2 flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Abbrechen
</Button>
<Button
type="button"
size="sm"
disabled={update.isPending}
onClick={() => update.mutate({ projectId, logoUrl: draft })}
>
Speichern
</Button>
</div>
</div>
);
}
return (
<button
type="button"
onClick={() => {
setDraft(logoUrl);
setEditing(true);
}}
className="group relative shrink-0"
title="Logo ändern"
>
<ProjectLogo url={logoUrl} name={name} size={56} />
<span className="absolute inset-0 flex items-center justify-center rounded-[var(--radius-md)] bg-black/40 text-white opacity-0 transition-opacity group-hover:opacity-100">
<Pencil size={16} />
</span>
</button>
);
}
function ZielSection({ projectId, project: p }: { projectId: string; project: ProjectDetail }) {
const utils = trpc.useUtils();
const [editing, setEditing] = useState(false);
@@ -0,0 +1 @@
ALTER TABLE "project" ADD COLUMN "logo_url" text;
File diff suppressed because it is too large. Load diff
+123 -116
View File
@@ -1,118 +1,125 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779736070490,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779737202483,
"tag": "0001_day_rhythm",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779737584310,
"tag": "0002_areas_projects",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1779737764727,
"tag": "0003_sales_marketing",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1779739115697,
"tag": "0004_is_admin",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779740456944,
"tag": "0005_todo_carry_over",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780410287425,
"tag": "0006_time_entry",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780415208906,
"tag": "0007_project_description",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780577284868,
"tag": "0008_lead_crm",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780669990802,
"tag": "0009_pm_insights",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780672129310,
"tag": "0010_leitfaden_phases",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780754557002,
"tag": "0011_lead_project_link",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780761840614,
"tag": "0012_todo_unplanned",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1780763141548,
"tag": "0013_parallel_timers",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1780764129593,
"tag": "0014_checkout_kimai",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1780765384409,
"tag": "0015_delete_break_entries",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779736070490,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779737202483,
"tag": "0001_day_rhythm",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779737584310,
"tag": "0002_areas_projects",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1779737764727,
"tag": "0003_sales_marketing",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1779739115697,
"tag": "0004_is_admin",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779740456944,
"tag": "0005_todo_carry_over",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780410287425,
"tag": "0006_time_entry",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780415208906,
"tag": "0007_project_description",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780577284868,
"tag": "0008_lead_crm",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780669990802,
"tag": "0009_pm_insights",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780672129310,
"tag": "0010_leitfaden_phases",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780754557002,
"tag": "0011_lead_project_link",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780761840614,
"tag": "0012_todo_unplanned",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1780763141548,
"tag": "0013_parallel_timers",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1780764129593,
"tag": "0014_checkout_kimai",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1780765384409,
"tag": "0015_delete_break_entries",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1780771242601,
"tag": "0016_project_logo_url",
"breakpoints": true
}
]
}
+2
View File
@@ -125,6 +125,8 @@ export const project = pgTable(
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
description: text('description'),
// Quadratisches Projektlogo als Data-URL (client-seitig auf 256px verkleinert).
logoUrl: text('logo_url'),
// Maxime / roter Faden (ein Satz) — Leitsatz fürs ganze Team (Leitfaden: Strategie).
maxime: text('maxime'),
// Definition (Leitfaden Phase 1) — Kurzfassung; Volltext-Steckbrief lebt in Outline.
+10
View File
@@ -3,10 +3,19 @@ import { z } from 'zod';
const ampelSchema = z.enum(['green', 'amber', 'red']);
const trendSchema = z.enum(['besser', 'gleich', 'schlechter']);
// Quadratisches Logo als Data-URL (client verkleinert auf 256px ≈ <100 KB).
const logoUrlSchema = z
.string()
.max(500_000)
.refine((s) => s.startsWith('data:image/') || /^https?:\/\//.test(s), {
message: 'Logo muss ein Bild (Data-URL) oder eine http(s)-URL sein.',
});
export const createProjectInput = z.object({
areaId: z.string().uuid(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
logoUrl: logoUrlSchema.optional(),
kind: z.enum(['kundenprojekt', 'internes_projekt']),
verantwortlicheUserId: z.string().uuid().optional(),
arbeitsmodus: z.enum(['cycle', 'kanban']).optional(),
@@ -104,6 +113,7 @@ export const updateProjectInput = z.object({
nichtZiele: z.string().max(2000).nullable().optional(),
liefergegenstaende: z.string().max(2000).nullable().optional(),
zieldreieckFlex: zieldreieckFlexSchema.nullable().optional(),
logoUrl: logoUrlSchema.nullable().optional(),
outlineSteckbriefUrl: z.string().url().nullable().optional(),
planeProjectUrl: z.string().url().nullable().optional(),
risikolisteUrl: z.string().url().nullable().optional(),