Projektlogo: quadratisches Logo hochladen & anzeigen
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:
1 parent
45a87711fb
commit
1206dc5e0f
9 files changed
+3652
-119
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user