db395977-b1fb-4bf5-89ba-32fa96701360
This commit is contained in:
1 parent
e2c17aea18
commit
cd7591fd65
6 files changed
+257
-159
No files matched your search
@@ -92,54 +92,19 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/expenses/[id] — delete an expense.
|
||||
// DELETE /api/aos/expenses/[id] — DISABLED (GoBD).
|
||||
//
|
||||
// Cascade policy:
|
||||
// - DATEV-exported expenses cannot be deleted (GoBD lock). Returns 409.
|
||||
// - Expenses already converted to an invoice cannot be deleted. Returns 409.
|
||||
// - Otherwise hard delete — expenses are typically just bookkeeping drafts
|
||||
// and don't have downstream FKs.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.datevExported) {
|
||||
// Expenses are accounting records. Per GoBD they may be STORNO'd (reversed)
|
||||
// but never deleted. Use the dedicated storno flow at
|
||||
// PATCH /api/aos/expenses/[id]/storno instead — it sets status='rejected',
|
||||
// records a storno reason, and writes an AuditLog entry. The expense row is
|
||||
// retained indefinitely. DATEV-exported expenses are fully locked.
|
||||
export async function DELETE() {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense is DATEV-exported and cannot be deleted (GoBD)' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'expense already converted to an invoice — cancel the invoice first' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.expense.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense:delete',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
{
|
||||
error: 'Ausgaben können nicht gelöscht werden (GoBD). Bitte über „Storno“ stornieren.',
|
||||
useInstead: 'PATCH /api/aos/expenses/[id]/storno',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const stornoSchema = z.object({
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/expenses/[id]/storno — Storno (GoBD reversal).
|
||||
//
|
||||
// Expenses are accounting records: they may be STORNO'd (reversed) but never
|
||||
// deleted. Storno sets status='rejected' and records the reason + timestamp.
|
||||
// The original expense row is retained indefinitely for GoBD retention.
|
||||
//
|
||||
// Guard rails:
|
||||
// - DATEV-exported expenses are fully locked (immutable, § 146 AO). 409.
|
||||
// - Expenses already converted to an invoice cannot be storno'd directly —
|
||||
// the invoice must be storno'd instead (the booking flows from there). 409.
|
||||
// - Already-rejected expenses return 409 (idempotent: no double-storno).
|
||||
//
|
||||
// Audit: writes an AuditLog entry with action='expense.storno' capturing
|
||||
// before/after + reason. This is the durable record of the reversal.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = stornoSchema.safeParse(await req.json().catch(() => ({})))
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storno-Grund erforderlich', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { reason } = parsed.data
|
||||
|
||||
const existing = await db.expense.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// GoBD lock: DATEV-exported records are immutable.
|
||||
if (existing.datevExported) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe ist DATEV-exportiert und gesperrt (GoBD § 146 AO) — Storno nicht möglich.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// If already invoiced, the booking lives on the invoice — storno the invoice.
|
||||
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe bereits in eine Rechnung umgewandelt — bitte die Rechnung stornieren.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Idempotent: already storno'd.
|
||||
if (existing.status === 'rejected') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ausgabe ist bereits storniert.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const updated = await db.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'rejected' },
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'expense.storno',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify({
|
||||
status: existing.status,
|
||||
description: existing.description,
|
||||
amountCents: existing.amountCents,
|
||||
vendor: existing.vendor,
|
||||
}),
|
||||
after: JSON.stringify({
|
||||
status: 'rejected',
|
||||
stornoReason: reason,
|
||||
stornoAt: now,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expense: updated,
|
||||
stornoReason: reason,
|
||||
stornoAt: now,
|
||||
message: 'Ausgabe storniert (GoBD). Datensatz bleibt für Aufbewahrung erhalten.',
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,20 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// DELETE /api/aos/licenses/[id] — hard-delete a license.
|
||||
// DELETE /api/aos/licenses/[id] — DISABLED (GoBD).
|
||||
//
|
||||
// Cascade policy: a license may only be hard-deleted when:
|
||||
// - it has NO activations, AND
|
||||
// - it has NO linked instance (License 1:1 Instance), AND
|
||||
// - it has NO child module licenses (parentId === this id), AND
|
||||
// - it is in status 'cancelled' (cancelled licenses that have reached their
|
||||
// effective date are safe to remove from active bookkeeping).
|
||||
//
|
||||
// Otherwise the call returns 409 with the dependency counts.
|
||||
//
|
||||
// GoBD: if the license has been issued, activated, or used to bill a
|
||||
// customer, do NOT delete — cancel (cancel/route.ts) keeps the audit record.
|
||||
// This endpoint exists for genuine cleanup (e.g. demo licenses that never
|
||||
// started a subscription, or test fixtures).
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.license.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [activations, instances, modules] = await Promise.all([
|
||||
db.licenseActivation.count({ where: { licenseId: id } }),
|
||||
db.instance.count({ where: { licenseId: id } }),
|
||||
db.license.count({ where: { parentId: id } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = { activations, instances, modules }
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
|
||||
if (totalDeps > 0) {
|
||||
// Licenses carry billing/subscription data and are accounting records.
|
||||
// Per GoBD they may be CANCELLED but never deleted. Use the dedicated
|
||||
// cancel flow at PATCH /api/aos/licenses/[id]/cancel instead — it sets
|
||||
// status='cancelled', records cancellationEffectiveAt, and writes an
|
||||
// AuditLog entry. The license row is retained indefinitely.
|
||||
export async function DELETE() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'license has related records — cancel the license instead of deleting it',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
error: 'Lizenzen können nicht gelöscht werden (GoBD). Bitte über „Kündigen“ stornieren.',
|
||||
useInstead: 'PATCH /api/aos/licenses/[id]/cancel',
|
||||
},
|
||||
{ status: 409 }
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
|
||||
// Only allow deletion when the license is already cancelled or never started.
|
||||
// Active/trial/suspended licenses must go through the cancel flow.
|
||||
if (
|
||||
existing.status !== 'cancelled' &&
|
||||
existing.status !== 'revoked' &&
|
||||
existing.status !== 'expired'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `license is ${existing.status} — cancel before deleting (GoBD retention)` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.license.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'license:delete',
|
||||
entity: 'License',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ key: existing.key, status: existing.status, product: existing.product }),
|
||||
after: JSON.stringify({ deleted: true }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, deleted: true, id })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,10 @@ interface DeleteDialogProps {
|
||||
// still has related records that block deletion — we render that list
|
||||
// inside the dialog so the user knows exactly what to clean up first.
|
||||
//
|
||||
// GoBD: callers must NOT use this for invoices (no DELETE endpoint exists).
|
||||
// GoBD RULE — accounting entities must NOT use this dialog:
|
||||
// Invoices, Expenses, Licenses, Kassenbuch, Belege, Buchungssätze.
|
||||
// Those are cancel/storno-only (retained indefinitely). Only non-accounting
|
||||
// entities (Company, Deal, Project, Ticket, Instance, Repo, …) may hard-delete.
|
||||
export function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Receipt,
|
||||
TrendingUp,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Ban,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -19,13 +19,22 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format'
|
||||
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
|
||||
@@ -87,7 +96,7 @@ export function ExpensesView() {
|
||||
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)
|
||||
const [stornoTarget, setStornoTarget] = 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')
|
||||
@@ -207,7 +216,7 @@ export function ExpensesView() {
|
||||
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'
|
||||
const canStorno = !e.datevExported && e.status !== 'invoiced' && e.status !== 'rejected'
|
||||
return (
|
||||
<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>
|
||||
@@ -258,14 +267,14 @@ export function ExpensesView() {
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
{canStorno && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(e)}
|
||||
onClick={() => setStornoTarget(e)}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
title="Löschen"
|
||||
title="Storno (GoBD)"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Ban className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,20 +379,115 @@ export function ExpensesView() {
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
entityLabel="Ausgabe"
|
||||
entityName={deleteTarget.description}
|
||||
endpoint={`/api/aos/expenses/${deleteTarget.id}`}
|
||||
onDeleted={refetch}
|
||||
<StornoDialog
|
||||
expense={stornoTarget}
|
||||
onOpenChange={(o) => !o && setStornoTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StornoDialog — GoBD reversal of an expense (cancel, not delete).
|
||||
// Calls PATCH /api/aos/expenses/[id]/storno with a reason. The expense row is
|
||||
// retained (status='rejected'); an AuditLog entry is the durable record.
|
||||
// ---------------------------------------------------------------------------
|
||||
function StornoDialog({
|
||||
expense,
|
||||
onOpenChange,
|
||||
onDone,
|
||||
}: {
|
||||
expense: Expense | null
|
||||
onOpenChange: (o: boolean) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reset = () => setReason('')
|
||||
|
||||
const commit = async () => {
|
||||
if (!expense) return
|
||||
if (reason.trim().length < 3) {
|
||||
toast.error('Bitte einen Storno-Grund angeben (mind. 3 Zeichen).')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const t = toast.loading('Ausgabe wird storniert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/expenses/${expense.id}/storno`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Storno fehlgeschlagen')
|
||||
toast.success(res.message ?? 'Ausgabe storniert (GoBD)', { id: t, duration: 5000 })
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
onDone()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Storno fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!expense}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Ban className="size-4 text-muted-foreground" />
|
||||
Ausgabe stornieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{expense?.number} — {expense?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storno-reason" className="text-xs">
|
||||
Storno-Grund <span className="text-muted-foreground">(GoBD — wird im Audit-Log protokolliert)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="storno-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="z. B. Doppelte Buchung, falscher Betrag, Lieferung storniert…"
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Die Ausgabe wird auf „Abgelehnt“ gesetzt. Der Datensatz bleibt gemäß GoBD erhalten und wird
|
||||
nicht gelöscht. DATEV-exportierte Ausgaben sind gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || reason.trim().length < 3}
|
||||
onClick={() => void commit()}
|
||||
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Ban className="size-3.5" />}
|
||||
Storno
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Convert an Expense (view shape) into the form's value shape.
|
||||
function expenseToForm(e: Expense): ExpenseFormValue {
|
||||
return {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
CircleDollarSign,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -51,7 +50,6 @@ 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
|
||||
@@ -199,7 +197,6 @@ 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) {
|
||||
@@ -353,7 +350,6 @@ export function LicensesView() {
|
||||
onSuspend={(lic) => setSuspendTarget(lic)}
|
||||
onCancel={(lic) => setCancelTarget(lic)}
|
||||
onPromote={handlePromote}
|
||||
onDelete={(lic) => setDeleteTarget(lic)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -475,17 +471,6 @@ 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>
|
||||
)
|
||||
}
|
||||
@@ -499,13 +484,11 @@ 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
|
||||
@@ -639,9 +622,9 @@ function LicenseRow({
|
||||
</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>
|
||||
<Badge variant="outline" className="h-7 gap-1 text-[10px] text-muted-foreground" title="GoBD: storniert — Datensatz bleibt erhalten">
|
||||
<Ban className="size-3" />Storniert
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
Reference in new issue
Block a user