feat: full CRUD support — companies, deals, projects, expenses, tickets, licenses
API routes (new + modified): - POST /api/aos/crm/companies (create company) - PATCH/DELETE /api/aos/crm/companies/[id] (edit/delete with dependency check) - PATCH/DELETE /api/aos/crm/deals/[id] (edit/delete deal) - POST /api/aos/projects (create project) - PATCH/DELETE /api/aos/projects/[id] (edit/delete with dependency check) - POST /api/aos/expenses (create expense) - PATCH/DELETE /api/aos/expenses/[id] (edit/delete with GoBD lock) - PATCH/DELETE /api/aos/tickets/[id] (edit/delete with message check) - DELETE /api/aos/licenses/[id] (delete only if cancelled + no deps) - NO invoice delete endpoint (GoBD retention — storno only) UI components (new): - DeleteDialog: reusable AlertDialog with dependency list - CompanyFormDialog: create + edit (12 fields) - DealFormDialog: create + edit (replaces CreateDealDialog) - ProjectFormDialog: create + edit - ExpenseFormDialog: create + edit - TicketEditDialog: edit status/priority/category/assignee UI views (modified): - CRM: 'Neue Firma' + 'Neuer Deal' buttons, edit/delete per row - Projects: 'Projekt' button, edit/delete on cards - Expenses: 'Ausgabe' button, edit/delete per row (hidden if DATEV-locked) - Support: edit/delete in inbox header - Licenses: delete button (only if cancelled/revoked/expired) Cascade policy: - Every DELETE counts FK relations, returns 409 with dependency list - Hard delete only when no dependencies exist - GoBD: expenses locked if DATEV-exported or invoiced - Audit log on every create/update/delete
This commit is contained in:
1 parent
27669adc21
commit
aa075762b2
21 files changed
+2618
-40
No files matched your search
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export interface DealFormValue {
|
||||
id?: string
|
||||
companyId: string
|
||||
title: string
|
||||
valueEuro: string
|
||||
stage: string
|
||||
probability: string
|
||||
expectedClose: string
|
||||
lossReason: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
companies: { id: string; name: string }[]
|
||||
// When `deal` is null the dialog is in "create" mode.
|
||||
deal?: DealFormValue | null
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ value: 'lead', label: 'Lead' },
|
||||
{ value: 'qualified', label: 'Qualifiziert' },
|
||||
{ value: 'proposal', label: 'Angebot' },
|
||||
{ value: 'negotiation', label: 'Verhandlung' },
|
||||
{ value: 'won', label: 'Gewonnen' },
|
||||
{ value: 'lost', label: 'Verloren' },
|
||||
]
|
||||
|
||||
const EMPTY: DealFormValue = {
|
||||
companyId: '',
|
||||
title: '',
|
||||
valueEuro: '',
|
||||
stage: 'lead',
|
||||
probability: '10',
|
||||
expectedClose: '',
|
||||
lossReason: '',
|
||||
}
|
||||
|
||||
// Single dialog for create + edit of a CRM Deal.
|
||||
// - create mode → POST /api/aos/crm/deals
|
||||
// - edit mode → PATCH /api/aos/crm/deals/[id]
|
||||
export function DealFormDialog({ open, onOpenChange, companies, deal, onSaved }: Props) {
|
||||
const [form, setForm] = useState<DealFormValue>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const isEdit = !!deal?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(deal ?? EMPTY)
|
||||
}
|
||||
}, [open, deal])
|
||||
|
||||
// Auto-pick the first company for create mode if none selected.
|
||||
useEffect(() => {
|
||||
if (open && !isEdit && !form.companyId && companies[0]) {
|
||||
setForm((f) => ({ ...f, companyId: companies[0].id }))
|
||||
}
|
||||
}, [open, isEdit, form.companyId, companies])
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.companyId) { toast.error('Firma auswählen'); return }
|
||||
if (!form.title.trim()) { toast.error('Titel eingeben'); return }
|
||||
const valueCents = Math.round((parseFloat(form.valueEuro.replace(',', '.')) || 0) * 100)
|
||||
const probability = Math.min(100, Math.max(0, parseInt(form.probability, 10) || 0))
|
||||
const payload: Record<string, unknown> = {
|
||||
companyId: form.companyId,
|
||||
title: form.title.trim(),
|
||||
valueCents,
|
||||
stage: form.stage,
|
||||
probability,
|
||||
expectedClose: form.expectedClose || null,
|
||||
}
|
||||
if (form.stage === 'lost' && form.lossReason.trim()) {
|
||||
payload.lossReason = form.lossReason.trim()
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const t = toast.loading(isEdit ? 'Deal wird gespeichert…' : 'Deal wird angelegt…')
|
||||
try {
|
||||
const url = isEdit ? `/api/aos/crm/deals/${deal!.id}` : '/api/aos/crm/deals'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || `${method} failed`)
|
||||
}
|
||||
toast.success(isEdit ? 'Deal aktualisiert' : 'Deal angelegt', { id: t })
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Deal bearbeiten' : 'Neuer Deal'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Deal-Daten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
||||
: 'Legt einen Deal in der Pipeline an. Audit-Log wird automatisch geschrieben.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Firma</Label>
|
||||
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Titel</Label>
|
||||
<Input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Wert (€)</Label>
|
||||
<Input value={form.valueEuro} onChange={(e) => setForm((f) => ({ ...f, valueEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Wahrscheinlichkeit (%)</Label>
|
||||
<Input value={form.probability} onChange={(e) => setForm((f) => ({ ...f, probability: e.target.value }))} placeholder="50" inputMode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Phase</Label>
|
||||
<Select value={form.stage} onValueChange={(v) => setForm((f) => ({ ...f, stage: v }))}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STAGES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Erwarteter Abschluss</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedClose ? form.expectedClose.slice(0, 10) : ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expectedClose: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.stage === 'lost' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Verlustgrund</Label>
|
||||
<Input value={form.lossReason} onChange={(e) => setForm((f) => ({ ...f, lossReason: e.target.value }))} placeholder="z.B. Preis, Konkurrenz, Timing" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user