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:
Z User committed 2026-10-29 13:32:17 +00:00
1 parent 27669adc21
commit aa075762b2
21 files changed
+2618 -40

No files matched your search

@@ -0,0 +1,182 @@
'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 { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
// Shape shared between the table row and the edit form. Optional fields
// are stringified so the form has a single value type.
export interface CompanyFormValue {
id?: string
name: string
legalForm: string
vatId: string
registerCourt: string
registerNumber: string
website: string
email: string
phone: string
city: string
zip: string
street: string
tags: string
}
export const EMPTY_COMPANY: CompanyFormValue = {
name: '',
legalForm: '',
vatId: '',
registerCourt: '',
registerNumber: '',
website: '',
email: '',
phone: '',
city: '',
zip: '',
street: '',
tags: '',
}
interface Props {
open: boolean
onOpenChange: (o: boolean) => void
// When `company` is null, the dialog is in "create" mode.
company?: CompanyFormValue | null
onSaved: () => void
}
// Single dialog for create + edit of a CRM Company.
// - create mode → POST /api/aos/crm/companies
// - edit mode → PATCH /api/aos/crm/companies/[id]
// Both write an audit entry on the server side (GoBD).
export function CompanyFormDialog({ open, onOpenChange, company, onSaved }: Props) {
const [form, setForm] = useState<CompanyFormValue>(EMPTY_COMPANY)
const [saving, setSaving] = useState(false)
const isEdit = !!company?.id
useEffect(() => {
if (open) {
setForm(company ?? EMPTY_COMPANY)
}
}, [open, company])
const update = (k: keyof CompanyFormValue, v: string) =>
setForm((f) => ({ ...f, [k]: v }))
const submit = async () => {
if (!form.name.trim()) { toast.error('Firmenname ist Pflicht'); return }
setSaving(true)
const t = toast.loading(isEdit ? 'Firma wird gespeichert…' : 'Firma wird angelegt…')
try {
// Normalise empty strings → null on the server side (Prisma schema is
// nullable; the PATCH handler accepts strings or null).
const payload: Record<string, string | null> = {
name: form.name.trim(),
legalForm: form.legalForm.trim() || null,
vatId: form.vatId.trim() || null,
registerCourt: form.registerCourt.trim() || null,
registerNumber: form.registerNumber.trim() || null,
website: form.website.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
city: form.city.trim() || null,
zip: form.zip.trim() || null,
street: form.street.trim() || null,
tags: form.tags.trim() || null,
}
const url = isEdit ? `/api/aos/crm/companies/${company!.id}` : '/api/aos/crm/companies'
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 ? 'Firma aktualisiert' : 'Firma 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-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Firma bearbeiten' : 'Neue Firma'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Stammdaten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
: 'Erfasse eine neue Firma im CRM. Audit-Log wird automatisch geschrieben.'}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<Field label="Firmenname *" value={form.name} onChange={(v) => update('name', v)} className="col-span-2" />
<Field label="Rechtsform" value={form.legalForm} onChange={(v) => update('legalForm', v)} placeholder="GmbH, UG, e.K." />
<Field label="USt-IdNr." value={form.vatId} onChange={(v) => update('vatId', v)} placeholder="DE123456789" mono />
<Field label="Registergericht" value={form.registerCourt} onChange={(v) => update('registerCourt', v)} placeholder="Amtsgericht Hamburg" />
<Field label="HRB-Nummer" value={form.registerNumber} onChange={(v) => update('registerNumber', v)} mono />
<Field label="Website" value={form.website} onChange={(v) => update('website', v)} placeholder="mueller.de" />
<Field label="E-Mail" value={form.email} onChange={(v) => update('email', v)} placeholder="info@firma.de" />
<Field label="Telefon" value={form.phone} onChange={(v) => update('phone', v)} placeholder="+49 40 …" />
<Field label="Straße" value={form.street} onChange={(v) => update('street', v)} />
<Field label="PLZ" value={form.zip} onChange={(v) => update('zip', v)} />
<Field label="Ort" value={form.city} onChange={(v) => update('city', v)} className="col-span-2" />
<Field label="Tags (kommasepariert)" value={form.tags} onChange={(v) => update('tags', v)} placeholder="kunde, agency, vip" className="col-span-2" />
</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>
)
}
function Field({
label,
value,
onChange,
placeholder,
mono,
className,
}: {
label: string
value: string
onChange: (v: string) => void
placeholder?: string
mono?: boolean
className?: string
}) {
return (
<div className={`space-y-1.5 ${className ?? ''}`}>
<Label className="text-xs">{label}</Label>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`h-9 text-xs ${mono ? 'font-mono' : ''}`}
/>
</div>
)
}