0bb384e7-e18c-49c5-b7ef-fe73701ab6ab
This commit is contained in:
1 parent
c4d338ee48
commit
8c34778db3
8 files changed
+97
-28
No files matched your search
@@ -73,7 +73,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
})
|
})
|
||||||
|
|
||||||
// TODO (production): send email to company.email with subject
|
// TODO (production): send email to company.email with subject
|
||||||
// "Ihre Bayi-Bewerbung wurde genehmigt — {discountPct}% Rabatt"
|
// "Ihre Reseller-Bewerbung wurde genehmigt — {discountPct}% Rabatt"
|
||||||
// containing the discount and a link to the agency portal.
|
// containing the discount and a link to the agency portal.
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
// Parse metadata for Dokploy domain ID
|
// Parse metadata for Dokploy domain ID
|
||||||
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {}
|
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {}
|
||||||
|
|
||||||
|
let dokployFailed = false
|
||||||
|
let dnsFailed = false
|
||||||
|
|
||||||
// 1. Remove Dokploy domain + database + application (idempotent)
|
// 1. Remove Dokploy domain + database + application (idempotent)
|
||||||
if (dokploy) {
|
if (dokploy) {
|
||||||
try {
|
try {
|
||||||
@@ -189,6 +192,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
|
data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
dokployFailed = true
|
||||||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||||
await db.instanceEvent.create({
|
await db.instanceEvent.create({
|
||||||
data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||||
@@ -198,7 +202,11 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
steps.push('dokploy.skipped (not configured)')
|
steps.push('dokploy.skipped (not configured)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Remove DNS A record (if per-instance, not wildcard)
|
// 2. Remove DNS A record (if per-instance, not wildcard).
|
||||||
|
// IMPORTANT: Dokploy silme başarısız olsa bile DNS'i silmeye çalış —
|
||||||
|
// orphaned DNS record subdomain takeover riski taşır. Ama DNS silme
|
||||||
|
// hata verirse dnsRecordId'yi KORU, instance'ı failed yap ki reconciler
|
||||||
|
// sonra tekrar deneyebilsin.
|
||||||
if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
|
if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
|
||||||
try {
|
try {
|
||||||
await dns.deleteRecord(instance.dnsRecordId)
|
await dns.deleteRecord(instance.dnsRecordId)
|
||||||
@@ -207,12 +215,50 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
|
data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
dnsFailed = true
|
||||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: { instanceId: id, type: 'delete.dns.error', step: 'dns', result: 'error', details: JSON.stringify({ recordId: instance.dnsRecordId, error: e instanceof Error ? e.message : 'unknown' }) },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Mark deleted (keep the row for audit — GoBD)
|
// 3. Decide final state.
|
||||||
|
// - Both succeeded → 'deleted' (clear all IDs + secrets)
|
||||||
|
// - Either failed → 'failed' (keep IDs for reconciler retry)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
if (dokployFailed || dnsFailed) {
|
||||||
|
await db.instance.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
lastError: `deletion partial: ${steps.join(', ')}`,
|
||||||
|
lastErrorAt: now,
|
||||||
|
// Keep dokployAppId + dnsRecordId so reconciler can retry.
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: { instanceId: id, type: 'delete.partial', step: 'done', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) },
|
||||||
|
})
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'admin:ui',
|
||||||
|
action: 'instance.delete.partial',
|
||||||
|
entity: 'Instance',
|
||||||
|
entityId: id,
|
||||||
|
after: JSON.stringify({ subdomain: instance.subdomain, steps, dokployFailed, dnsFailed, message: 'reconciler will retry' }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
steps,
|
||||||
|
message: 'Löschung teilweise fehlgeschlagen — Reconciler wird erneut versuchen. Dokploy + Hetzner A record birlikte silinmedi; orphaned resource riski için instance failed durumda.',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Both succeeded → mark deleted (keep row for audit — GoBD)
|
||||||
await db.instance.update({
|
await db.instance.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -226,7 +272,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Unlink from license (keep license, just remove instance ref)
|
// 5. Unlink from license (keep license, just remove instance ref)
|
||||||
if (instance.licenseId) {
|
if (instance.licenseId) {
|
||||||
await db.license.update({
|
await db.license.update({
|
||||||
where: { id: instance.licenseId },
|
where: { id: instance.licenseId },
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {}
|
const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {}
|
||||||
const steps: string[] = []
|
const steps: string[] = []
|
||||||
|
let dokployFailed = false
|
||||||
|
let dnsFailed = false
|
||||||
|
|
||||||
// Teardown Dokploy resources
|
// Teardown Dokploy resources
|
||||||
if (dokploy) {
|
if (dokploy) {
|
||||||
@@ -91,21 +93,41 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
steps.push(...r.steps)
|
steps.push(...r.steps)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
dokployFailed = true
|
||||||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove DNS record
|
// Remove DNS record (even if Dokploy failed — orphaned DNS = takeover risk)
|
||||||
if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') {
|
if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') {
|
||||||
try {
|
try {
|
||||||
await dns.deleteRecord(inst.dnsRecordId)
|
await dns.deleteRecord(inst.dnsRecordId)
|
||||||
steps.push('dns.removed')
|
steps.push('dns.removed')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
dnsFailed = true
|
||||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark deleted
|
// Decide final state — both must succeed for full deletion
|
||||||
|
if (dokployFailed || dnsFailed) {
|
||||||
|
// Partial failure → keep IDs for next reconciler run
|
||||||
|
await db.instance.update({
|
||||||
|
where: { id: inst.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
lastError: `cleanup partial: ${steps.join(', ')}`,
|
||||||
|
lastErrorAt: now,
|
||||||
|
// Keep dokployAppId + dnsRecordId for retry
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.instanceEvent.create({
|
||||||
|
data: { instanceId: inst.id, type: 'cleanup.delete.partial', step: 'reconciler', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) },
|
||||||
|
})
|
||||||
|
results.failed++
|
||||||
|
results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`)
|
||||||
|
} else {
|
||||||
|
// Both succeeded → mark deleted (clear IDs + secrets)
|
||||||
await db.instance.update({
|
await db.instance.update({
|
||||||
where: { id: inst.id },
|
where: { id: inst.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -132,6 +154,7 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
results.deleted++
|
results.deleted++
|
||||||
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.failed++
|
results.failed++
|
||||||
results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`)
|
results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
|||||||
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
|
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
|
||||||
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
||||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
||||||
{ id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
{ id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
||||||
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const NAV: {
|
|||||||
group: 'nav.operations',
|
group: 'nav.operations',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||||
{ id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' },
|
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
|
||||||
{ id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
|
{ id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
|
||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
|
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
|
||||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||||
agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' },
|
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
||||||
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ export function AgencyView() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 md:p-6">
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Bayis"
|
title="Reseller"
|
||||||
description="Reseller-Verwaltung: Bayi-Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Bayis mit ihren Kunden einsehen."
|
description="Reseller-Verwaltung: Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Reseller mit ihren Kunden einsehen."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -148,7 +148,7 @@ export function AgencyView() {
|
|||||||
hint="wartet auf Freigabe"
|
hint="wartet auf Freigabe"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Genehmigte Bayis"
|
label="Genehmigte Reseller"
|
||||||
value={stats.approved}
|
value={stats.approved}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
hint="aktiv im System"
|
hint="aktiv im System"
|
||||||
@@ -163,7 +163,7 @@ export function AgencyView() {
|
|||||||
label="Ø Rabatt"
|
label="Ø Rabatt"
|
||||||
value={`${avgDiscount}%`}
|
value={`${avgDiscount}%`}
|
||||||
icon={Percent}
|
icon={Percent}
|
||||||
hint={agencies.length ? `über ${agencies.length} Bayis` : '—'}
|
hint={agencies.length ? `über ${agencies.length} Reseller` : '—'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ function PendingApplicationsSection({
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">
|
<h2 className="text-sm font-semibold tracking-tight">
|
||||||
Bewerbende Bayis
|
Bewerbende Reseller
|
||||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
{applications.length} offen
|
{applications.length} offen
|
||||||
</span>
|
</span>
|
||||||
@@ -230,7 +230,7 @@ function PendingApplicationsSection({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
||||||
<p className="max-w-sm text-xs text-muted-foreground">
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
Neue Bayi-Bewerbungen erscheinen hier.
|
Neue Reseller-Bewerbungen erscheinen hier.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,11 +393,11 @@ function ApproveDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="size-4" />
|
<CheckCircle2 className="size-4" />
|
||||||
Bayi-Bewerbung genehmigen
|
Reseller-Bewerbung genehmigen
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<span className="font-medium text-foreground">{application.company.name}</span> wird
|
<span className="font-medium text-foreground">{application.company.name}</span> wird
|
||||||
als Bayi freigeschaltet. Der Rabatt wird auf alle vom Bayi angelegten Kundenlizenzierungen
|
als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen
|
||||||
angewendet.
|
angewendet.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -524,7 +524,7 @@ function RejectDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<XCircle className="size-4" />
|
<XCircle className="size-4" />
|
||||||
Bayi-Bewerbung ablehnen
|
Reseller-Bewerbung ablehnen
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<span className="font-medium text-foreground">{application.company.name}</span>
|
<span className="font-medium text-foreground">{application.company.name}</span>
|
||||||
@@ -577,7 +577,7 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">
|
<h2 className="text-sm font-semibold tracking-tight">
|
||||||
Genehmigte Bayis
|
Genehmigte Reseller
|
||||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
{agencies.length} aktiv
|
{agencies.length} aktiv
|
||||||
</span>
|
</span>
|
||||||
@@ -590,23 +590,23 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
|||||||
<Users className="size-5 text-muted-foreground" />
|
<Users className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Noch keine Bayis genehmigt</p>
|
<p className="text-sm font-medium">Noch keine Reseller genehmigt</p>
|
||||||
<p className="max-w-sm text-xs text-muted-foreground">
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Bayis ({agencies.length})</CardTitle>
|
<CardTitle className="text-sm">Reseller ({agencies.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">Bayi</th>
|
<th className="px-4 py-2 font-medium">Reseller</th>
|
||||||
<th className="px-4 py-2 font-medium">Rabatt</th>
|
<th className="px-4 py-2 font-medium">Rabatt</th>
|
||||||
<th className="px-4 py-2 font-medium text-right">Kunden</th>
|
<th className="px-4 py-2 font-medium text-right">Kunden</th>
|
||||||
<th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
<th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
||||||
@@ -692,7 +692,7 @@ function ClientsSheet({
|
|||||||
{agency.name}
|
{agency.name}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||||
{agency._count.licenses} Lizenz(en).
|
{agency._count.licenses} Lizenz(en).
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
@@ -702,8 +702,8 @@ function ClientsSheet({
|
|||||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
|
<div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
|
||||||
<Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
<Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Bayi kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
|
Reseller kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
|
||||||
an den Bayi geroutet, nicht an uns.
|
an den Reseller geroutet, nicht an uns.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@ const de: Dict = {
|
|||||||
'nav.projects': 'Projekte',
|
'nav.projects': 'Projekte',
|
||||||
'nav.support': 'Support',
|
'nav.support': 'Support',
|
||||||
'nav.licenses': 'Lizenzen',
|
'nav.licenses': 'Lizenzen',
|
||||||
'nav.agency': 'Bayis',
|
'nav.agency': 'Reseller',
|
||||||
'nav.instances': 'Instanzen',
|
'nav.instances': 'Instanzen',
|
||||||
'nav.contracts': 'Verträge',
|
'nav.contracts': 'Verträge',
|
||||||
'nav.expenses': 'Ausgaben',
|
'nav.expenses': 'Ausgaben',
|
||||||
@@ -86,7 +86,7 @@ const en: Dict = {
|
|||||||
'nav.projects': 'Projects',
|
'nav.projects': 'Projects',
|
||||||
'nav.support': 'Support',
|
'nav.support': 'Support',
|
||||||
'nav.licenses': 'Licenses',
|
'nav.licenses': 'Licenses',
|
||||||
'nav.agency': 'Resellers (Bayis)',
|
'nav.agency': 'Resellers',
|
||||||
'nav.instances': 'Instances',
|
'nav.instances': 'Instances',
|
||||||
'nav.contracts': 'Contracts',
|
'nav.contracts': 'Contracts',
|
||||||
'nav.expenses': 'Expenses',
|
'nav.expenses': 'Expenses',
|
||||||
|
|||||||
Reference in new issue
Block a user