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,208 @@
'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 ProjectFormValue {
id?: string
companyId: string
name: string
type: string
budgetType: string
budgetHours: string
fixedPriceEuro: string
startDate: string
dueDate: string
}
interface Props {
open: boolean
onOpenChange: (o: boolean) => void
companies: { id: string; name: string }[]
// When `project` is null the dialog is in "create" mode.
project?: ProjectFormValue | null
onSaved: () => void
}
const TYPES = [
{ value: 'website', label: 'Website' },
{ value: 'shop', label: 'Shop' },
{ value: 'app', label: 'App' },
{ value: 'retainer', label: 'Retainer' },
]
const BUDGET_TYPES = [
{ value: 'fixed', label: 'Fixpreis' },
{ value: 'time-materials', label: 'Time & Materials' },
{ value: 'retainer', label: 'Retainer' },
]
const EMPTY: ProjectFormValue = {
companyId: '',
name: '',
type: 'website',
budgetType: 'fixed',
budgetHours: '',
fixedPriceEuro: '',
startDate: '',
dueDate: '',
}
// Single dialog for create + edit of a Project.
// - create mode → POST /api/aos/projects
// - edit mode → PATCH /api/aos/projects/[id]
export function ProjectFormDialog({ open, onOpenChange, companies, project, onSaved }: Props) {
const [form, setForm] = useState<ProjectFormValue>(EMPTY)
const [saving, setSaving] = useState(false)
const isEdit = !!project?.id
useEffect(() => {
if (open) {
setForm(project ?? EMPTY)
}
}, [open, project])
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.name.trim()) { toast.error('Projektname eingeben'); return }
const fixedPriceCents = form.fixedPriceEuro
? Math.round((parseFloat(form.fixedPriceEuro.replace(',', '.')) || 0) * 100)
: null
const budgetHours = form.budgetHours ? parseInt(form.budgetHours, 10) || null : null
const payload: Record<string, unknown> = {
companyId: form.companyId,
name: form.name.trim(),
type: form.type,
budgetType: form.budgetType,
budgetHours,
fixedPriceCents,
startDate: form.startDate || null,
dueDate: form.dueDate || null,
}
setSaving(true)
const t = toast.loading(isEdit ? 'Projekt wird gespeichert…' : 'Projekt wird angelegt…')
try {
const url = isEdit ? `/api/aos/projects/${project!.id}` : '/api/aos/projects'
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 ? 'Projekt aktualisiert' : 'Projekt 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 ? 'Projekt bearbeiten' : 'Neues Projekt'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Projekt-Stammdaten aktualisieren. Audit-Log wird automatisch geschrieben (GoBD).'
: 'Legt ein neues Projekt 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">Projektname</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: 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">Typ</Label>
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{TYPES.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">Budget-Typ</Label>
<Select value={form.budgetType} onValueChange={(v) => setForm((f) => ({ ...f, budgetType: v }))}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{BUDGET_TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Fixpreis (€)</Label>
<Input value={form.fixedPriceEuro} onChange={(e) => setForm((f) => ({ ...f, fixedPriceEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Budget-Stunden</Label>
<Input value={form.budgetHours} onChange={(e) => setForm((f) => ({ ...f, budgetHours: e.target.value }))} placeholder="120" inputMode="numeric" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Startdatum</Label>
<Input type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Fälligkeit</Label>
<Input type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</div>
</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>
)
}