b6167bcb-7363-4695-9481-addd81de4367

This commit is contained in:
Z User committed 2026-10-26 13:40:52 +00:00
1 parent fdc3b82d10
commit d226dabba3
136 files changed
+13904 -2

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
'use client'
import { useState } from 'react'
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface CrmData {
companies: (Company & { persons: Person[]; deals: Deal[] })[]
deals: (Deal & { company: Company })[]
persons: (Person & { company: Company | null })[]
pipeline: { stage: string; label: string; deals: (Deal & { company: Company })[] }[]
}
type Company = { id: string; name: string; legalForm: string | null; vatId: string | null; website: string | null; email: string | null; phone: string | null; city: string | null; zip: string | null; street: string | null; source: string | null; tags: string | null }
type Person = { id: string; firstName: string; lastName: string; email: string | null; phone: string | null; role: string | null }
type Deal = { id: string; title: string; valueCents: number; probability: number; stage: string; expectedClose: Date | string | null; lossReason: string | null }
const STAGE_COLORS: Record<string, string> = {
lead: 'bg-muted-foreground/20',
qualified: 'bg-muted-foreground/35',
proposal: 'bg-muted-foreground/55',
negotiation: 'bg-muted-foreground/75',
won: 'bg-foreground',
lost: 'bg-muted-foreground/15',
}
export function CrmView() {
const { data, loading } = useFetch<CrmData>('/api/aos/crm')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-64" />
))}
</div>
</div>
)
}
const totalPipeline = data.deals.filter((d) => d.stage !== 'lost' && d.stage !== 'won').reduce((s, d) => s + d.valueCents, 0)
const wonTotal = data.deals.filter((d) => d.stage === 'won').reduce((s, d) => s + d.valueCents, 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm">
<Plus className="size-4" />
Firma
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Firmen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.companies.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Ansprechpartner</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.persons.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Offene Pipeline</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalPipeline)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Gewonnen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(wonTotal)}</p>
</Card>
</div>
<Tabs defaultValue="pipeline">
<TabsList>
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
<TabsTrigger value="companies">Firmen</TabsTrigger>
<TabsTrigger value="persons">Ansprechpartner</TabsTrigger>
</TabsList>
<TabsContent value="pipeline" className="mt-4">
<PipelineBoard pipeline={data.pipeline} />
</TabsContent>
<TabsContent value="companies" className="mt-4">
<CompaniesTable companies={data.companies} />
</TabsContent>
<TabsContent value="persons" className="mt-4">
<PersonsTable persons={data.persons} />
</TabsContent>
</Tabs>
</div>
)
}
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
return (
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
{pipeline.map((col) => {
const value = col.deals.reduce((s, d) => s + d.valueCents, 0)
return (
<div key={col.stage} className="flex w-72 shrink-0 flex-col rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<span className={cn('size-1.5 rounded-full', STAGE_COLORS[col.stage])} />
<span className="text-xs font-semibold">{col.label}</span>
</div>
<span className="text-[10px] text-muted-foreground">{col.deals.length}</span>
</div>
{value > 0 && (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground">
{formatEuro(value)}
</div>
)}
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
{col.deals.map((d) => (
<Card key={d.id} className="cursor-pointer p-3 transition hover:border-foreground/30 hover:shadow-sm">
<p className="text-xs font-medium leading-snug">{d.title}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
<Badge variant="outline" className="text-[10px]">{d.probability}%</Badge>
</div>
{d.expectedClose && (
<p className="mt-1.5 text-[10px] text-muted-foreground">
⟶ {formatDate(d.expectedClose)}
</p>
)}
{d.lossReason && (
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
Verloren: {d.lossReason}
</p>
)}
</Card>
))}
{col.deals.length === 0 && (
<div className="rounded border border-dashed border-border py-6 text-center text-[11px] text-muted-foreground">
Leer
</div>
)}
</div>
</div>
)
})}
</div>
)
}
function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
const [q, setQ] = useState('')
const filtered = companies.filter(
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-sm">Firmen ({filtered.length})</CardTitle>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filtern…"
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rechtsform</th>
<th className="px-4 py-2 font-medium">Ort</th>
<th className="px-4 py-2 font-medium">USt-IdNr.</th>
<th className="px-4 py-2 font-medium">Kontakte</th>
<th className="px-4 py-2 font-medium">Deals</th>
<th className="px-4 py-2 font-medium">Tags</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{c.name}</span>
{c.website && (
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<Globe className="size-3" />{c.website}
</a>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5">
{c.legalForm && <Badge variant="outline" className="text-[10px]">{c.legalForm}</Badge>}
</td>
<td className="px-4 py-2.5">
{c.city && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />{c.zip} {c.city}
</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[11px] text-muted-foreground">{c.vatId ?? '—'}</td>
<td className="px-4 py-2.5">
<span className="flex items-center gap-1 text-xs">
<UsersIcon className="size-3 text-muted-foreground" />{c.persons.length}
</span>
</td>
<td className="px-4 py-2.5 text-xs">{c.deals.length}</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{c.tags?.split(',').map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Kontakt</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{persons.map((p) => (
<tr key={p.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 font-medium">{p.firstName} {p.lastName}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{p.company?.name ?? '—'}</td>
<td className="px-4 py-2.5 text-xs">{p.role ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex flex-col gap-0.5 text-[11px] text-muted-foreground">
{p.email && <span className="flex items-center gap-1"><Mail className="size-3" />{p.email}</span>}
{p.phone && <span className="flex items-center gap-1"><Phone className="size-3" />{p.phone}</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}