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
@@ -13,6 +13,8 @@ import {
|
||||
Timer,
|
||||
Play,
|
||||
Pause,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -31,6 +33,8 @@ import { toast } from 'sonner'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ProjectFormDialog, type ProjectFormValue } from '../crud/project-form-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -81,6 +85,13 @@ const TASK_ICON = {
|
||||
export function ProjectsView() {
|
||||
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<ProjectFormValue | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
|
||||
// Fetch companies for the create/edit dialog (CRM endpoint already returns them).
|
||||
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||
const companies = crmData?.companies ?? []
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -104,7 +115,7 @@ export function ProjectsView() {
|
||||
title="Projekte"
|
||||
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setCreateOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Projekt
|
||||
</Button>
|
||||
@@ -139,7 +150,7 @@ export function ProjectsView() {
|
||||
return (
|
||||
<Card
|
||||
key={p.id}
|
||||
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
||||
className={cn('group cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -147,7 +158,27 @@ export function ProjectsView() {
|
||||
<p className="truncate text-sm font-semibold">{p.name}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setEditTarget(projectToForm(p)); setCreateOpen(true) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(p) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
@@ -193,6 +224,29 @@ export function ProjectsView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
companies={companies}
|
||||
project={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Projekt"
|
||||
entityName={deleteTarget.name}
|
||||
endpoint={`/api/aos/projects/${deleteTarget.id}`}
|
||||
onDeleted={() => {
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||
setDeleteTarget(null)
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -423,6 +477,21 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: (
|
||||
)
|
||||
}
|
||||
|
||||
// Convert a Project (view shape) into the form's value shape.
|
||||
function projectToForm(p: Project): ProjectFormValue {
|
||||
return {
|
||||
id: p.id,
|
||||
companyId: p.company.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
budgetType: p.budgetType,
|
||||
budgetHours: p.budgetHours ? String(p.budgetHours) : '',
|
||||
fixedPriceEuro: p.fixedPriceCents ? (p.fixedPriceCents / 100).toString().replace('.', ',') : '',
|
||||
startDate: p.startDate ? p.startDate.slice(0, 10) : '',
|
||||
dueDate: p.dueDate ? p.dueDate.slice(0, 10) : '',
|
||||
}
|
||||
}
|
||||
|
||||
function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
Reference in new issue
Block a user