b6167bcb-7363-4695-9481-addd81de4367
This commit is contained in:
1 parent
fdc3b82d10
commit
d226dabba3
136 files changed
+13904
-2
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user