287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
'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 { CreateDealDialog } from '../create-deal-dialog'
|
|
import { formatEuro, formatDate } from '@/lib/format'
|
|
import { useAos } from '@/store/aos'
|
|
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, refetch } = useFetch<CrmData>('/api/aos/crm')
|
|
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
|
|
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" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="size-4" />
|
|
Deal
|
|
</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} onSelectCompany={setSelectedCompany} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="persons" className="mt-4">
|
|
<PersonsTable persons={data.persons} onSelectCompany={setSelectedCompany} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<CreateDealDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
|
onCreated={refetch}
|
|
/>
|
|
</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, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
|
|
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="cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
|
|
<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" onClick={(e) => e.stopPropagation()} 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, onSelectCompany }: { persons: CrmData['persons']; onSelectCompany: (id: string) => void }) {
|
|
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="cursor-pointer transition hover:bg-muted/40" onClick={() => p.companyId && onSelectCompany(p.companyId)}>
|
|
<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>
|
|
)
|
|
}
|