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
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText } from 'lucide-react'
|
||||
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -9,7 +9,9 @@ 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 { DealFormDialog, type DealFormValue } from '../crud/deal-form-dialog'
|
||||
import { CompanyFormDialog, type CompanyFormValue } from '../crud/company-form-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -45,7 +47,12 @@ const STAGE_COLORS: Record<string, string> = {
|
||||
export function CrmView() {
|
||||
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
||||
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [dealDialogOpen, setDealDialogOpen] = useState(false)
|
||||
const [editingDeal, setEditingDeal] = useState<DealFormValue | null>(null)
|
||||
const [companyDialogOpen, setCompanyDialogOpen] = useState(false)
|
||||
const [editingCompany, setEditingCompany] = useState<CompanyFormValue | null>(null)
|
||||
const [deleteCompany, setDeleteCompany] = useState<Company & { persons: Person[]; deals: Deal[] } | null>(null)
|
||||
const [deleteDeal, setDeleteDeal] = useState<Deal & { company: Company } | null>(null)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [importStep, setImportStep] = useState<'upload' | 'map' | 'result'>('upload')
|
||||
const [csvText, setCsvText] = useState('')
|
||||
@@ -81,7 +88,11 @@ export function CrmView() {
|
||||
<Upload className="size-4" />
|
||||
CSV Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingCompany(null); setCompanyDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Firma
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditingDeal(null); setDealDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Deal
|
||||
</Button>
|
||||
@@ -116,11 +127,20 @@ export function CrmView() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pipeline" className="mt-4">
|
||||
<PipelineBoard pipeline={data.pipeline} />
|
||||
<PipelineBoard
|
||||
pipeline={data.pipeline}
|
||||
onEditDeal={(d) => { setEditingDeal(dealToForm(d)); setDealDialogOpen(true) }}
|
||||
onDeleteDeal={(d) => setDeleteDeal(d)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="companies" className="mt-4">
|
||||
<CompaniesTable companies={data.companies} onSelectCompany={setSelectedCompany} />
|
||||
<CompaniesTable
|
||||
companies={data.companies}
|
||||
onSelectCompany={setSelectedCompany}
|
||||
onEdit={(c) => { setEditingCompany(companyToForm(c)); setCompanyDialogOpen(true) }}
|
||||
onDelete={(c) => setDeleteCompany(c)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="persons" className="mt-4">
|
||||
@@ -128,13 +148,43 @@ export function CrmView() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CreateDealDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
<DealFormDialog
|
||||
open={dealDialogOpen}
|
||||
onOpenChange={setDealDialogOpen}
|
||||
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
||||
onCreated={refetch}
|
||||
deal={editingDeal}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
<CompanyFormDialog
|
||||
open={companyDialogOpen}
|
||||
onOpenChange={setCompanyDialogOpen}
|
||||
company={editingCompany}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteCompany && (
|
||||
<DeleteDialog
|
||||
open={!!deleteCompany}
|
||||
onOpenChange={(o) => !o && setDeleteCompany(null)}
|
||||
entityLabel="Firma"
|
||||
entityName={deleteCompany.name}
|
||||
endpoint={`/api/aos/crm/companies/${deleteCompany.id}`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteDeal && (
|
||||
<DeleteDialog
|
||||
open={!!deleteDeal}
|
||||
onOpenChange={(o) => !o && setDeleteDeal(null)}
|
||||
entityLabel="Deal"
|
||||
entityName={deleteDeal.title}
|
||||
endpoint={`/api/aos/crm/deals/${deleteDeal.id}`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CSV Import Dialog */}
|
||||
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
@@ -295,7 +345,15 @@ export function CrmView() {
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
function PipelineBoard({
|
||||
pipeline,
|
||||
onEditDeal,
|
||||
onDeleteDeal,
|
||||
}: {
|
||||
pipeline: CrmData['pipeline']
|
||||
onEditDeal: (d: Deal & { company: Company }) => void
|
||||
onDeleteDeal: (d: Deal & { company: Company }) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
|
||||
{pipeline.map((col) => {
|
||||
@@ -316,8 +374,28 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
)}
|
||||
<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>
|
||||
<Card key={d.id} className="group p-3 transition hover:border-foreground/30 hover:shadow-sm">
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<p className="min-w-0 flex-1 text-xs font-medium leading-snug">{d.title}</p>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEditDeal(d) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteDeal(d) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -348,7 +426,17 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
|
||||
function CompaniesTable({
|
||||
companies,
|
||||
onSelectCompany,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
companies: CrmData['companies']
|
||||
onSelectCompany: (id: string) => void
|
||||
onEdit: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||
onDelete: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const filtered = companies.filter(
|
||||
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
@@ -376,11 +464,12 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
||||
<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>
|
||||
<th className="w-20 px-4 py-2 font-medium text-right">Aktionen</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)}>
|
||||
<tr key={c.id} className="group 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" />
|
||||
@@ -418,6 +507,26 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(c) }}
|
||||
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(); onDelete(c) }}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -488,3 +597,39 @@ function parseCSVLine(line: string): string[] {
|
||||
fields.push(current)
|
||||
return fields
|
||||
}
|
||||
|
||||
// Convert a Deal (CRM view shape) into the form's value shape.
|
||||
function dealToForm(d: Deal & { company: Company }): DealFormValue {
|
||||
const dateStr = d.expectedClose
|
||||
? (typeof d.expectedClose === 'string' ? d.expectedClose : d.expectedClose.toISOString()).slice(0, 10)
|
||||
: ''
|
||||
return {
|
||||
id: d.id,
|
||||
companyId: d.company.id,
|
||||
title: d.title,
|
||||
valueEuro: (d.valueCents / 100).toString().replace('.', ','),
|
||||
stage: d.stage,
|
||||
probability: String(d.probability),
|
||||
expectedClose: dateStr,
|
||||
lossReason: d.lossReason ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a Company (CRM view shape) into the form's value shape.
|
||||
function companyToForm(c: Company): CompanyFormValue {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
legalForm: c.legalForm ?? '',
|
||||
vatId: c.vatId ?? '',
|
||||
registerCourt: '',
|
||||
registerNumber: '',
|
||||
website: c.website ?? '',
|
||||
email: c.email ?? '',
|
||||
phone: c.phone ?? '',
|
||||
city: c.city ?? '',
|
||||
zip: c.zip ?? '',
|
||||
street: c.street ?? '',
|
||||
tags: c.tags ?? '',
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Loader2,
|
||||
Receipt,
|
||||
TrendingUp,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -22,6 +24,8 @@ import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/form
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
|
||||
import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
|
||||
interface Expense {
|
||||
id: string
|
||||
@@ -33,6 +37,8 @@ interface Expense {
|
||||
taxBp: number
|
||||
grossCents: number
|
||||
date: string
|
||||
companyId: string | null
|
||||
projectId: string | null
|
||||
billable: boolean
|
||||
markupPct: number
|
||||
recurring: boolean
|
||||
@@ -79,6 +85,15 @@ const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
export function ExpensesView() {
|
||||
const { data, loading, refetch } = useFetch<ExpensesData>('/api/aos/expenses')
|
||||
const [converting, setConverting] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<ExpenseFormValue | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Expense | null>(null)
|
||||
|
||||
// Companies + projects needed for the create/edit dialog (project linking).
|
||||
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||
const { data: projectsData } = useFetch<{ projects: { id: string; name: string }[] } | null>('/api/aos/projects')
|
||||
const companies = crmData?.companies ?? []
|
||||
const projectOptions = projectsData?.projects ?? []
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -121,7 +136,7 @@ export function ExpensesView() {
|
||||
title="Ausgaben"
|
||||
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true) }}>
|
||||
<Plus className="size-4" />
|
||||
Ausgabe
|
||||
</Button>
|
||||
@@ -191,8 +206,10 @@ export function ExpensesView() {
|
||||
{expenses.map((e) => {
|
||||
const st = STATUS_META[e.status] ?? STATUS_META.recorded
|
||||
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
|
||||
const canEdit = !e.datevExported && e.status !== 'invoiced'
|
||||
const canDelete = !e.datevExported && e.status !== 'invoiced'
|
||||
return (
|
||||
<tr key={e.id} className="transition hover:bg-muted/40">
|
||||
<tr key={e.id} className="group transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
|
||||
@@ -214,24 +231,44 @@ export function ExpensesView() {
|
||||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{canConvert ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 text-[11px]"
|
||||
onClick={() => convert(e)}
|
||||
disabled={converting === e.id}
|
||||
>
|
||||
{converting === e.id ? <Loader2 className="size-3 animate-spin" /> : <ArrowRight className="size-3" />}
|
||||
Zur Rechnung
|
||||
</Button>
|
||||
) : e.status === 'invoiced' ? (
|
||||
<Badge variant="outline" className="gap-1 text-[9px]">
|
||||
<Receipt className="size-2.5" />Berechnet
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{canConvert ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 text-[11px]"
|
||||
onClick={() => convert(e)}
|
||||
disabled={converting === e.id}
|
||||
>
|
||||
{converting === e.id ? <Loader2 className="size-3 animate-spin" /> : <ArrowRight className="size-3" />}
|
||||
Zur Rechnung
|
||||
</Button>
|
||||
) : e.status === 'invoiced' ? (
|
||||
<Badge variant="outline" className="gap-1 text-[9px]">
|
||||
<Receipt className="size-2.5" />Berechnet
|
||||
</Badge>
|
||||
) : null}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditTarget(expenseToForm(e)); setDialogOpen(true) }}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(e)}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -323,6 +360,45 @@ export function ExpensesView() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ExpenseFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
companies={companies}
|
||||
projects={projectOptions}
|
||||
expense={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Ausgabe"
|
||||
entityName={deleteTarget.description}
|
||||
endpoint={`/api/aos/expenses/${deleteTarget.id}`}
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Convert an Expense (view shape) into the form's value shape.
|
||||
function expenseToForm(e: Expense): ExpenseFormValue {
|
||||
return {
|
||||
id: e.id,
|
||||
categoryId: e.categoryId,
|
||||
vendor: e.vendor ?? '',
|
||||
description: e.description,
|
||||
amountEuro: (e.amountCents / 100).toString().replace('.', ','),
|
||||
taxBp: String(e.taxBp),
|
||||
date: e.date ? e.date.slice(0, 10) : '',
|
||||
companyId: e.companyId ?? '',
|
||||
projectId: e.projectId ?? '',
|
||||
billable: e.billable,
|
||||
markupPct: String(e.markupPct),
|
||||
recurring: e.recurring,
|
||||
recurringInterval: e.recurringInterval ?? 'monthly',
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
CircleDollarSign,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -50,6 +51,7 @@ import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -197,6 +199,7 @@ export function LicensesView() {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [suspendTarget, setSuspendTarget] = useState<License | null>(null)
|
||||
const [cancelTarget, setCancelTarget] = useState<License | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<License | null>(null)
|
||||
const [filter, setFilter] = useState<FilterKey>('all')
|
||||
|
||||
if (loading || !data) {
|
||||
@@ -350,6 +353,7 @@ export function LicensesView() {
|
||||
onSuspend={(lic) => setSuspendTarget(lic)}
|
||||
onCancel={(lic) => setCancelTarget(lic)}
|
||||
onPromote={handlePromote}
|
||||
onDelete={(lic) => setDeleteTarget(lic)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -471,6 +475,17 @@ export function LicensesView() {
|
||||
onOpenChange={(o) => !o && setCancelTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Lizenz"
|
||||
entityName={deleteTarget.key}
|
||||
endpoint={`/api/aos/licenses/${deleteTarget.id}`}
|
||||
description="Gekündigte Lizenzen ohne Aktivierungen, Instanzen oder Module können gelöscht werden. Audit-Log wird geschrieben (GoBD)."
|
||||
onDeleted={refetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -484,11 +499,13 @@ function LicenseRow({
|
||||
onSuspend,
|
||||
onCancel,
|
||||
onPromote,
|
||||
onDelete,
|
||||
}: {
|
||||
license: License
|
||||
onSuspend: (l: License) => void
|
||||
onCancel: (l: License) => void
|
||||
onPromote: (l: License) => void
|
||||
onDelete: (l: License) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const st = LIC_STATUS[license.status] ?? LIC_STATUS.active
|
||||
@@ -621,6 +638,11 @@ function LicenseRow({
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{cancelled && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onDelete(license)} title="Löschen">
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Loader2,
|
||||
Send,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -23,6 +25,8 @@ import { PageHeader } from '../primitives'
|
||||
import { formatRelative, formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { TicketEditDialog, type TicketFormValue } from '../crud/ticket-edit-dialog'
|
||||
import { DeleteDialog } from '../crud/delete-dialog'
|
||||
|
||||
interface TicketMsg { id: string; fromType: string; fromName: string; body: string; createdAt: string }
|
||||
interface Ticket {
|
||||
@@ -89,6 +93,8 @@ export function SupportView() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null)
|
||||
const [replyDraft, setReplyDraft] = useState('')
|
||||
const [editTarget, setEditTarget] = useState<TicketFormValue | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Ticket | null>(null)
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -185,7 +191,31 @@ export function SupportView() {
|
||||
{/* Ticket list */}
|
||||
<Card className="lg:col-span-2 overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Inbox ({tickets.length})</CardTitle>
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<span>Inbox ({tickets.length})</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{selected && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditTarget(ticketToForm(selected))}
|
||||
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={() => setDeleteTarget(selected)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<ScrollArea className="h-[72vh]">
|
||||
<div className="space-y-1.5 p-2">
|
||||
@@ -356,6 +386,41 @@ export function SupportView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketEditDialog
|
||||
open={!!editTarget}
|
||||
onOpenChange={(o) => !o && setEditTarget(null)}
|
||||
ticket={editTarget}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Ticket"
|
||||
entityName={deleteTarget.subject}
|
||||
endpoint={`/api/aos/tickets/${deleteTarget.id}`}
|
||||
description="Tickets mit Nachrichten können nicht gelöscht werden — schließe das Ticket stattdessen. Audit-Log wird geschrieben (GoBD)."
|
||||
onDeleted={() => {
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||
setDeleteTarget(null)
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Convert a Ticket (view shape) into the form's value shape.
|
||||
function ticketToForm(t: Ticket): TicketFormValue {
|
||||
return {
|
||||
id: t.id,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
categoryId: t.categoryId,
|
||||
assignee: t.assignee ?? '',
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user