a6c0f3de-eeb0-4d76-a57f-f600dd133c06

This commit is contained in:
Z User committed 2026-10-28 15:39:30 +00:00
1 parent 2aa3496a90
commit 762f3d7373
141 files changed
+4485 -69

No files matched your search

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
discountPct: z.number().int().min(0).max(20),
reviewedBy: z.string().optional(),
})
// PATCH /api/aos/agency/[id]/approve
// Admin approves an agency application. Sets Company.agencyStatus = "approved",
// Company.agencyDiscountPct = discountPct, and marks the application approved.
// In production an email would be sent to the applicant with their discount.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'discountPct (0-20) required', details: parsed.error.flatten() }, { status: 400 })
}
const { discountPct, reviewedBy } = parsed.data
const application = await db.agencyApplication.findUnique({
where: { id },
include: { company: true },
})
if (!application) {
return NextResponse.json({ error: 'application not found' }, { status: 404 })
}
if (application.status === 'approved') {
return NextResponse.json({ error: 'already approved' }, { status: 409 })
}
const now = new Date()
await db.$transaction([
db.agencyApplication.update({
where: { id },
data: {
status: 'approved',
discountPct,
reviewedBy: reviewedBy ?? 'admin:ui',
reviewedAt: now,
},
}),
db.company.update({
where: { id: application.companyId },
data: {
isAgency: true,
agencyStatus: 'approved',
agencyApprovedAt: now,
agencyDiscountPct: discountPct,
agencyRejectedAt: null,
agencyRejectReason: null,
},
}),
])
await db.auditLog.create({
data: {
actor: reviewedBy ?? 'admin:ui',
action: 'agency.approve',
entity: 'AgencyApplication',
entityId: id,
after: JSON.stringify({
company: application.company.name,
discountPct,
approvedAt: now,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// TODO (production): send email to company.email with subject
// "Ihre Bayi-Bewerbung wurde genehmigt — {discountPct}% Rabatt"
// containing the discount and a link to the agency portal.
return NextResponse.json({
ok: true,
status: 'approved',
discountPct,
email: application.company.email,
message: `Genehmigt mit ${discountPct}% Rabatt. E-Mail an ${application.company.email ?? '(keine E-Mail hinterlegt)'} würde versendet.`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(500),
reviewedBy: z.string().optional(),
})
// PATCH /api/aos/agency/[id]/reject
// Admin rejects an agency application. Sets Company.agencyStatus = "rejected".
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'reason required', details: parsed.error.flatten() }, { status: 400 })
}
const { reason, reviewedBy } = parsed.data
const application = await db.agencyApplication.findUnique({
where: { id },
include: { company: true },
})
if (!application) {
return NextResponse.json({ error: 'application not found' }, { status: 404 })
}
const now = new Date()
await db.$transaction([
db.agencyApplication.update({
where: { id },
data: {
status: 'rejected',
rejectReason: reason,
reviewedBy: reviewedBy ?? 'admin:ui',
reviewedAt: now,
},
}),
db.company.update({
where: { id: application.companyId },
data: {
agencyStatus: 'rejected',
agencyRejectedAt: now,
agencyRejectReason: reason,
},
}),
])
await db.auditLog.create({
data: {
actor: reviewedBy ?? 'admin:ui',
action: 'agency.reject',
entity: 'AgencyApplication',
entityId: id,
after: JSON.stringify({ company: application.company.name, reason }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'rejected', reason })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
companyId: z.string().min(1),
businessModel: z.string().max(200).optional(),
expectedClientVolume: z.string().max(100).optional(),
referredBy: z.string().max(200).optional(),
})
// POST /api/aos/agency/apply
// A company applies to become an agency (reseller). Sets Company.agencyStatus
// to "pending" and creates an AgencyApplication record. Admin must approve.
export async function POST(req: Request) {
try {
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { companyId, businessModel, expectedClientVolume, referredBy } = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
if (company.isAgency && company.agencyStatus === 'approved') {
return NextResponse.json({ error: 'already an approved agency' }, { status: 409 })
}
// Upsert application (one per company — @unique on companyId)
const application = await db.agencyApplication.upsert({
where: { companyId },
create: {
companyId,
status: 'pending',
businessModel,
expectedClientVolume,
referredBy,
},
update: {
status: 'pending',
businessModel,
expectedClientVolume,
referredBy,
rejectReason: null,
reviewedBy: null,
reviewedAt: null,
},
})
// Mark company as pending-agency
await db.company.update({
where: { id: companyId },
data: {
isAgency: true,
agencyStatus: 'pending',
agencyAppliedAt: new Date(),
},
})
await db.auditLog.create({
data: {
actor: `company:${companyId}`,
action: 'agency.apply',
entity: 'AgencyApplication',
entityId: application.id,
after: JSON.stringify({ company: company.name, businessModel, expectedClientVolume }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, applicationId: application.id, status: 'pending' }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/agency
// Returns all agency applications + approved agencies. Admin-only in practice
// (no scope check here because AOS is single-tenant dev mode).
export async function GET() {
try {
const [applications, agencies] = await Promise.all([
db.agencyApplication.findMany({
orderBy: { appliedAt: 'desc' },
include: { company: true },
}),
db.company.findMany({
where: { isAgency: true, agencyStatus: 'approved' },
orderBy: { agencyApprovedAt: 'desc' },
include: {
agencyClients: { select: { id: true, name: true, email: true } },
_count: { select: { agencyClients: true, licenses: true } },
},
}),
])
const pending = applications.filter((a) => a.status === 'pending')
const reviewed = applications.filter((a) => a.status !== 'pending')
return NextResponse.json({
applications: { pending, reviewed },
agencies,
stats: {
pending: pending.length,
approved: agencies.length,
totalApplications: applications.length,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const createSchema = z.object({
name: z.string().min(1).max(200),
legalForm: z.string().max(50).optional(),
vatId: z.string().max(50).optional(),
website: z.string().max(200).optional(),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
city: z.string().max(100).optional(),
zip: z.string().max(20).optional(),
street: z.string().max(200).optional(),
// The agency creating this client. Required. The new client will have
// parentAgencyId set to this, so their tickets route to the agency.
parentAgencyId: z.string().min(1),
})
// GET /api/aos/clients?agencyId=X
// Returns clients belonging to an agency (or all clients if no agencyId).
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const agencyId = url.searchParams.get('agencyId')
const where = agencyId ? { parentAgencyId: agencyId } : {}
const clients = await db.company.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { licenses: true, tickets: true } },
},
})
return NextResponse.json({ clients })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/clients
// An agency onboards a new client. The client is created with parentAgencyId
// set to the agency's company id, so all future tickets auto-route to agency.
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { parentAgencyId, ...clientData } = parsed.data
// Verify the agency exists and is approved.
const agency = await db.company.findUnique({ where: { id: parentAgencyId } })
if (!agency) {
return NextResponse.json({ error: 'agency not found' }, { status: 404 })
}
if (!agency.isAgency || agency.agencyStatus !== 'approved') {
return NextResponse.json({ error: 'company is not an approved agency' }, { status: 403 })
}
const client = await db.company.create({
data: {
...clientData,
source: 'agency',
parentAgencyId,
},
})
await db.auditLog.create({
data: {
actor: `agency:${agency.name}`,
action: 'client.create',
entity: 'Company',
entityId: client.id,
after: JSON.stringify({ name: client.name, parentAgencyId, agencyDiscountPct: agency.agencyDiscountPct }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ client }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
View File
File mode changed.
View File
File mode changed.
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(500).optional(),
immediate: z.boolean().optional(), // force immediate (admin only)
})
// PATCH /api/aos/licenses/[id]/cancel
// Client cancels (kündigt) a subscription.
//
// German B2B law: 3-month (90-day) notice period. The cancellation takes
// effect at the end of the notice period (cancellationEffectiveAt).
//
// Special case: within the 14-day trial, the client may cancel with immediate
// effect — no charge, subscription never starts.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json().catch(() => ({})))
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { reason, immediate } = parsed.data
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
if (license.status === 'cancelled' || license.cancelledAt) {
return NextResponse.json({ error: 'already cancelled' }, { status: 409 })
}
const now = new Date()
const inTrial = license.trialEndsAt && now < license.trialEndsAt
// Effective cancellation date:
// - In trial (first 14 days): immediate (next day at most)
// - After trial: notice period (90 days) from now
// - immediate=true (admin override): now
let effectiveAt: Date
if (immediate || inTrial) {
effectiveAt = now
} else {
effectiveAt = new Date(now.getTime() + (license.noticePeriodDays || 90) * 86400000)
}
// If cancelling during trial, the subscription never starts — set
// validUntil to the trial end (so the license stays active until trial ends
// then expires). If immediate, set validUntil to now.
const newValidUntil = immediate
? now
: inTrial
? license.trialEndsAt
: effectiveAt
await db.license.update({
where: { id },
data: {
status: immediate ? 'cancelled' : 'active', // stays active until effective date unless immediate
cancelledAt: now,
cancellationEffectiveAt: effectiveAt,
cancellationReason: reason,
validUntil: newValidUntil,
},
})
// Transitive cancellation of module licenses (if core)
let cancelledModules = 0
if (!license.parentId) {
const r = await db.license.updateMany({
where: { parentId: id, status: { not: 'cancelled' } },
data: {
status: immediate ? 'cancelled' : 'active',
cancelledAt: now,
cancellationEffectiveAt: effectiveAt,
cancellationReason: reason,
validUntil: newValidUntil,
},
})
cancelledModules = r.count
}
await db.auditLog.create({
data: {
actor: 'client:cancel',
action: inTrial ? 'license.cancel:trial' : 'license.cancel:notice',
entity: 'License',
entityId: id,
after: JSON.stringify({
key: license.key.slice(0, 16) + '…',
inTrial,
immediate: immediate || inTrial,
effectiveAt,
cancelledModules,
reason,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
cancelledAt: now,
effectiveAt,
inTrial: !!inTrial,
immediate: immediate || inTrial,
cancelledModules,
message: inTrial
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.'
: `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/licenses/[id]/promote
// Body: { environment: "demo" | "production" }
// Reactivates a suspended/cancelled license AND/OR promotes demo -> production.
// PRD §6.4: "reactivate on payment".
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await req.json().catch(() => ({}))
const environment = body.environment === 'demo' ? 'demo' : 'production'
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
let validUntil = license.validUntil
if (environment === 'production' && license.environment === 'demo') {
const fourteenDays = 14 * 86400000
if (license.validUntil && license.validUntil.getTime() - license.createdAt.getTime() <= fourteenDays + 86400000) {
validUntil = null
}
} else if (environment === 'demo') {
validUntil = new Date(Date.now() + 14 * 86400000)
}
await db.license.update({
where: { id },
data: {
status: 'active',
environment,
suspendReason: null,
suspendedAt: null,
// Clear cancellation if reactivating
cancelledAt: null,
cancellationEffectiveAt: null,
cancellationReason: null,
validUntil,
},
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.reactivate',
entity: 'License',
entityId: id,
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', environment, status: 'active' }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'active', environment })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(300),
})
// PATCH /api/aos/licenses/[id]/suspend
// Suspend (bloke) a license. Transitive suspension of module licenses.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'reason required', details: parsed.error.flatten() }, { status: 400 })
}
const { reason } = parsed.data
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
const now = new Date()
await db.license.update({
where: { id },
data: { status: 'suspended', suspendReason: reason, suspendedAt: now },
})
let suspendedModules = 0
if (!license.parentId) {
const r = await db.license.updateMany({
where: { parentId: id, status: { not: 'suspended' } },
data: { status: 'suspended', suspendReason: `parent suspended: ${reason}`, suspendedAt: now },
})
suspendedModules = r.count
}
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.suspend',
entity: 'License',
entityId: id,
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', reason, suspendedModules }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'suspended', reason, suspendedModules })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+213
View File
@@ -0,0 +1,213 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
const schema = z.object({
key: z.string().min(1),
module: z.string().optional(),
domain: z.string().optional(),
version: z.string().optional(),
fingerprint: z.string().optional(),
})
// POST /api/aos/licenses/check
// License-server check endpoint for client apps (restov1, aos-cms, ...).
// Returns the full license bundle (core + modules) with feature flags the
// client app should gate, plus billing/trial/cancellation state.
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'license')
if (rateLimited) return rateLimited
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { key, module: moduleKey, domain, version, fingerprint } = parsed.data
const license = await db.license.findUnique({
where: { key },
include: { activations: true, company: true },
})
if (!license) {
return NextResponse.json({
valid: false,
error: 'license_not_found',
message: 'License key not found',
}, { status: 404 })
}
// If a module is requested, this key must be either the module license itself
// or a core license with a child module license of that module key.
// We type loosely to accommodate core vs. module shape differences (module
// has its own `module` field, core has nested `modules`).
let targetLicense: {
status: string; validUntil: Date | null; trialEndsAt: Date | null;
environment: string; product: string; plan: string; module: string | null;
maxActivations: number; suspendReason: string | null; id: string;
billingCycle: string; priceCents: number; discountPct: number;
netPriceCents: number; cancelledAt: Date | null;
cancellationEffectiveAt: Date | null; cancellationReason: string | null;
noticePeriodDays: number; subscriptionStartsAt: Date | null;
} = license
if (moduleKey) {
if (license.module === moduleKey) {
// direct module license key
} else if (!license.parentId) {
// core license — look up child module (include company for consistency)
const child = await db.license.findFirst({
where: { parentId: license.id, module: moduleKey },
include: { company: true, activations: true },
})
if (!child) {
return NextResponse.json({
valid: false,
error: 'module_not_licensed',
message: `Module "${moduleKey}" is not licensed for this core license`,
core: { key: license.key.slice(0, 16) + '…', product: license.product, status: license.status },
}, { status: 403 })
}
targetLicense = child
} else {
return NextResponse.json({
valid: false,
error: 'module_mismatch',
message: `This key is a module license for "${license.module}", not "${moduleKey}"`,
}, { status: 403 })
}
}
const now = new Date()
// Status computation (with cancellation handling)
let status: 'active' | 'suspended' | 'expired' | 'revoked' | 'cancelled' | 'demo' | 'trial'
const isRevoked = targetLicense.status === 'revoked'
const isExpired =
targetLicense.status === 'expired' ||
(targetLicense.validUntil && targetLicense.validUntil < now)
const isSuspended = targetLicense.status === 'suspended'
const isCancelled = targetLicense.status === 'cancelled' ||
(targetLicense.cancellationEffectiveAt && targetLicense.cancellationEffectiveAt <= now)
const inTrial = targetLicense.trialEndsAt && now < targetLicense.trialEndsAt
const coreBlocked =
!license.parentId &&
(license.status === 'suspended' || license.status === 'expired' ||
license.status === 'revoked' || license.status === 'cancelled')
if (isRevoked) status = 'revoked'
else if (isExpired) status = 'expired'
else if (isCancelled) status = 'cancelled'
else if (isSuspended || (targetLicense !== license && coreBlocked)) status = 'suspended'
else if (inTrial) status = 'trial'
else if (targetLicense.environment === 'demo') status = 'demo'
else status = 'active'
// Granted modules (only for core licenses)
const childModules = !license.parentId
? await db.license.findMany({ where: { parentId: license.id } })
: []
const grantedModules = !license.parentId
? childModules.map((m) => ({
module: m.module,
key: m.key.slice(0, 16) + '…',
status:
m.status === 'revoked' ? 'revoked'
: m.status === 'cancelled' || (m.cancellationEffectiveAt && m.cancellationEffectiveAt <= now) ? 'cancelled'
: m.status === 'expired' || (m.validUntil && m.validUntil < now) ? 'expired'
: m.status === 'suspended' || coreBlocked ? 'suspended'
: m.trialEndsAt && now < m.trialEndsAt ? 'trial'
: m.environment === 'demo' ? 'demo'
: 'active',
environment: m.environment,
validUntil: m.validUntil,
}))
: undefined
// Touch activation if domain matches
if (domain) {
const activation = license.activations.find((a) => a.domain === domain)
if (activation) {
await db.licenseActivation.update({
where: { id: activation.id },
data: {
lastCheck: now,
version: version ?? activation.version,
fingerprint: fingerprint ?? activation.fingerprint,
},
})
}
}
await db.auditLog.create({
data: {
actor: `client:${domain ?? 'unknown'}`,
action: ['active', 'trial', 'demo'].includes(status) ? 'license.check:ok' : 'license.check:blocked',
entity: 'License',
entityId: targetLicense.id,
after: JSON.stringify({
key: key.slice(0, 16) + '…',
module: moduleKey ?? null,
domain,
status,
environment: targetLicense.environment,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// Feature flags the client app must honor
const validStatuses = ['active', 'trial', 'demo']
const features = {
enabled: status === 'active',
demoMode: status === 'demo',
trialMode: status === 'trial',
watermark: status === 'demo' || status === 'trial',
blocked: ['suspended', 'expired', 'revoked', 'cancelled'].includes(status),
banner:
status === 'suspended' ? 'license_suspended'
: status === 'expired' ? 'license_expired'
: status === 'revoked' ? 'license_revoked'
: status === 'cancelled' ? 'license_cancelled'
: status === 'demo' ? 'license_demo'
: status === 'trial' ? 'license_trial'
: null,
}
// Billing info (so the client portal can show subscription status)
const billing = !license.parentId ? {
billingCycle: license.billingCycle,
priceCents: license.priceCents,
discountPct: license.discountPct,
netPriceCents: license.netPriceCents,
trialEndsAt: license.trialEndsAt,
subscriptionStartsAt: license.subscriptionStartsAt,
cancelledAt: license.cancelledAt,
cancellationEffectiveAt: license.cancellationEffectiveAt,
cancellationReason: license.cancellationReason,
noticePeriodDays: license.noticePeriodDays,
validUntil: license.validUntil,
} : undefined
return NextResponse.json({
valid: validStatuses.includes(status),
status,
environment: targetLicense.environment,
product: targetLicense.product,
plan: targetLicense.plan,
module: targetLicense.module,
validUntil: targetLicense.validUntil,
suspendReason: targetLicense.suspendReason ?? (coreBlocked && targetLicense !== license ? license.suspendReason : null),
maxActivations: targetLicense.maxActivations,
features,
modules: grantedModules,
billing,
checkedAt: now.toISOString(),
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+265 -1
View File
@@ -1,8 +1,80 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const PLANS = ['starter', 'pro', 'enterprise'] as const
const ENVIRONMENTS = ['demo', 'production'] as const
const BILLING_CYCLES = ['monthly', 'yearly'] as const
const PLAN_PREFIX: Record<string, string> = { starter: 'ST', pro: 'PRO', enterprise: 'ENT' }
// Default monthly prices per plan (cents). Used when no explicit priceCents given.
const PLAN_PRICES: Record<string, number> = { starter: 2900, pro: 5000, enterprise: 15000 }
// Yearly discount applied on top of any agency discount (10% off yearly total).
const YEARLY_DISCOUNT_PCT = 10
// Default notice period (Kündigung frist): 90 days (3 months, German B2B law).
const DEFAULT_NOTICE_PERIOD_DAYS = 90
// Trial duration: 14 days free.
const TRIAL_DAYS = 14
// Compute effective net price given base monthly price, billing cycle, and
// agency discount pct. Returns the per-cycle price + discount breakdown.
function computePricing(input: {
monthlyPriceCents: number
billingCycle: 'monthly' | 'yearly'
agencyDiscountPct: number
}) {
const { monthlyPriceCents, billingCycle, agencyDiscountPct } = input
const baseCents = billingCycle === 'yearly' ? monthlyPriceCents * 12 : monthlyPriceCents
const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0
const totalDiscountPct = agencyDiscountPct + yearlyPct
const discountAmount = Math.round((baseCents * totalDiscountPct) / 100)
const netPriceCents = baseCents - discountAmount
return {
priceCents: baseCents,
discountPct: totalDiscountPct,
netPriceCents,
breakdown: {
baseCents,
agencyDiscountPct,
yearlyDiscountPct: yearlyPct,
totalDiscountPct,
discountAmount,
netPriceCents,
},
}
}
function generateKey(plan: string, module?: string): string {
const prefix = PLAN_PREFIX[plan] ?? 'ST'
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const block = (n: number) =>
Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
const modTag = module ? '-M' : ''
return `AOS-${prefix}${modTag}-${block(4)}-${block(4)}-${block(4)}`
}
async function generateToken(): Promise<string> {
const crypto = await import('crypto')
return 'sig_' + crypto.randomBytes(32).toString('hex')
}
const createSchema = z.object({
companyId: z.string().min(1),
product: z.string().min(1).max(120),
plan: z.enum(PLANS),
environment: z.enum(ENVIRONMENTS).default('production'),
billingCycle: z.enum(BILLING_CYCLES).default('monthly'),
module: z.string().max(60).optional(),
parentId: z.string().optional(),
maxActivations: z.number().int().min(1).max(100).default(1),
validUntil: z.string().optional(),
monthlyPriceCents: z.number().int().min(0).optional(),
startTrial: z.boolean().optional(),
noticePeriodDays: z.number().int().min(0).max(365).optional(),
})
// GET /api/aos/licenses — issued licenses + asset/renewal register
export async function GET() {
try {
@@ -14,10 +86,44 @@ export async function GET() {
db.asset.findMany({ orderBy: { renewalDate: 'asc' } }),
])
// Fetch module licenses separately (avoids relation include issues
// when Prisma client is regenerated mid-process).
const coreIds = licenses.filter((l) => !l.parentId).map((l) => l.id)
const allModules = coreIds.length
? await db.license.findMany({
where: { parentId: { in: coreIds } },
include: { company: true, activations: true },
})
: []
const modulesByParent = new Map<string, typeof allModules>()
for (const m of allModules) {
if (!m.parentId) continue
const arr = modulesByParent.get(m.parentId) ?? []
arr.push(m)
modulesByParent.set(m.parentId, arr)
}
const licensesWithModules = licenses.map((l) => ({
...l,
modules: !l.parentId ? (modulesByParent.get(l.id) ?? []) : [],
}))
const activeLicenses = licenses.filter((l) => l.status === 'active').length
const suspended = licenses.filter((l) => l.status === 'suspended').length
const expired = licenses.filter((l) => l.status === 'expired').length
const cancelled = licenses.filter((l) => l.status === 'cancelled').length
const inTrial = licenses.filter(
(l) => l.trialEndsAt && new Date(l.trialEndsAt) > new Date()
).length
const totalActivations = licenses.reduce((s, l) => s + l.activations.length, 0)
const demoLicenses = licenses.filter((l) => l.environment === 'demo').length
// MRR estimate (sum of net monthly-equivalent of active non-cancelled licenses)
const mrrCents = licenses
.filter((l) => l.status === 'active' && !l.cancelledAt)
.reduce((s, l) => {
const monthly = l.billingCycle === 'yearly' ? Math.round(l.netPriceCents / 12) : l.netPriceCents
return s + monthly
}, 0)
const expiringSoon = assets.filter((a) => {
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
@@ -29,16 +135,20 @@ export async function GET() {
.reduce((s, a) => s + (a.costCentsPerMonth ?? 0), 0)
return NextResponse.json({
licenses,
licenses: licensesWithModules,
assets,
stats: {
activeLicenses,
suspended,
expired,
cancelled,
inTrial,
totalActivations,
expiringSoon: expiringSoon.length,
expiredAssets: expiredAssets.length,
monthlyRebill,
demoLicenses,
mrrCents,
},
})
} catch (err) {
@@ -46,3 +156,157 @@ export async function GET() {
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/licenses — issue a new license (core or module add-on)
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json(
{ error: 'validation failed', details: parsed.error.flatten() },
{ status: 400 }
)
}
const {
companyId, product, plan, environment, billingCycle, module: moduleKey,
parentId, maxActivations, validUntil, monthlyPriceCents, startTrial, noticePeriodDays,
} = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// If parentId given, validate the parent is a core license of same product+company.
if (parentId) {
const parent = await db.license.findUnique({ where: { id: parentId } })
if (!parent) {
return NextResponse.json({ error: 'parent license not found' }, { status: 404 })
}
if (parent.parentId) {
return NextResponse.json({ error: 'parent must be a core license, not a module' }, { status: 400 })
}
if (parent.product !== product) {
return NextResponse.json({ error: 'module product must match parent product' }, { status: 400 })
}
if (parent.companyId !== companyId) {
return NextResponse.json({ error: 'module must belong to the same company as the parent' }, { status: 400 })
}
if (!moduleKey) {
return NextResponse.json({ error: 'module key required for module add-on licenses' }, { status: 400 })
}
}
// Generate unique key (retry on collision)
let key = generateKey(plan, moduleKey)
for (let i = 0; i < 5; i++) {
const existing = await db.license.findUnique({ where: { key } })
if (!existing) break
key = generateKey(plan, moduleKey)
}
const token = await generateToken()
const now = new Date()
// --- Pricing computation ---
// Module licenses inherit the parent's pricing (no separate billing).
const isModule = !!parentId
const monthlyCents = monthlyPriceCents ?? PLAN_PRICES[plan] ?? 0
// Agency discount (only for non-module, agency-owned clients).
// If the company's parentAgency is an approved agency, use its discount.
let agencyDiscountPct = 0
if (!isModule && company.parentAgencyId) {
const agency = await db.company.findUnique({ where: { id: company.parentAgencyId } })
if (agency?.isAgency && agency.agencyStatus === 'approved') {
agencyDiscountPct = agency.agencyDiscountPct ?? 0
}
}
const pricing = isModule
? { priceCents: 0, discountPct: 0, netPriceCents: 0, breakdown: null }
: computePricing({
monthlyPriceCents: monthlyCents,
billingCycle,
agencyDiscountPct,
})
// --- Trial + subscription start ---
// Trial applies to: production core licenses issued to end clients (not modules,
// not demos, not agency-internal). Can be overridden via startTrial param.
const wantsTrial = startTrial ?? (!isModule && environment === 'production')
const trialEndsAt = wantsTrial ? new Date(now.getTime() + TRIAL_DAYS * 86400000) : null
const subscriptionStartsAt = wantsTrial
? new Date(trialEndsAt!.getTime() + 86400000) // day 15
: now
// --- validUntil ---
// - explicit validUntil wins
// - demo: 14 days from now
// - trial + monthly: 1 month from subscription start (first billing cycle)
// - trial + yearly: 1 year from subscription start
// - no trial + monthly: 1 month from now
// - no trial + yearly: 1 year from now
const validUntilDate = validUntil
? new Date(validUntil)
: environment === 'demo'
? new Date(now.getTime() + TRIAL_DAYS * 86400000)
: billingCycle === 'yearly'
? new Date(subscriptionStartsAt.getTime() + 365 * 86400000)
: new Date(subscriptionStartsAt.getTime() + 30 * 86400000)
const license = await db.license.create({
data: {
key,
companyId,
product,
plan,
status: 'active',
environment,
module: moduleKey ?? null,
parentId: parentId ?? null,
maxActivations,
validUntil: validUntilDate,
token,
noticePeriodDays: noticePeriodDays ?? DEFAULT_NOTICE_PERIOD_DAYS,
trialEndsAt,
subscriptionStartsAt,
billingCycle,
priceCents: pricing.priceCents,
discountPct: pricing.discountPct,
netPriceCents: pricing.netPriceCents,
},
include: { company: true, activations: true },
})
// Audit log (GoBD) — include full pricing breakdown
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.issue',
entity: 'License',
entityId: license.id,
after: JSON.stringify({
key,
product,
plan,
environment,
billingCycle,
module: moduleKey ?? null,
parentId: parentId ?? null,
company: company.name,
agencyDiscountPct,
maxActivations,
trialEndsAt,
subscriptionStartsAt,
validUntil: validUntilDate,
pricing: pricing.breakdown,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
View File
File mode changed.
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/tickets/[id]/escalate
// An agency escalates a ticket to us (the platform admin). After escalation,
// the ticket appears in our admin inbox (scope=admin sees escalated tickets).
export async function PATCH(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const ticket = await db.ticket.findUnique({ where: { id } })
if (!ticket) {
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
}
if (ticket.escalatedToAdmin) {
return NextResponse.json({ error: 'already escalated' }, { status: 409 })
}
await db.ticket.update({
where: { id },
data: {
escalatedToAdmin: true,
escalatedAt: new Date(),
status: 'open', // reopen for admin attention
},
})
await db.auditLog.create({
data: {
actor: 'agency:escalate',
action: 'ticket.escalate',
entity: 'Ticket',
entityId: id,
after: JSON.stringify({ number: ticket.number, escalatedAt: new Date() }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, escalatedToAdmin: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+124 -13
View File
@@ -1,28 +1,38 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
// GET /api/aos/tickets — ticket inbox with SLA status
export async function GET() {
// GET /api/aos/tickets — ticket inbox with SLA status + agency routing
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const scope = url.searchParams.get('scope') // 'all' | 'agency' | 'admin'
const agencyId = url.searchParams.get('agencyId') // company id of an agency
const tickets = await db.ticket.findMany({
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
include: { messages: { orderBy: { createdAt: 'asc' } } },
})
const companyIds = [...new Set(tickets.map((t) => t.companyId))]
const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } })
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
const companies = await db.company.findMany({
where: { id: { in: companyIds } },
select: { id: true, name: true, parentAgencyId: true, isAgency: true, agencyStatus: true },
})
const companyMap = new Map(companies.map((c) => [c.id, c]))
const enriched = tickets.map((t) => {
let enriched = tickets.map((t) => {
const lastMsg = t.messages[t.messages.length - 1]
const ageHours = (Date.now() - t.createdAt.getTime()) / 3600000
const slaBreached = !t.slaRespondedAt && ageHours > t.slaResponseHours
const resolveBreached = !t.slaResolvedAt && ageHours > t.slaResolveHours
const company = companyMap.get(t.companyId)
return {
...t,
companyName: companyMap.get(t.companyId) ?? '—',
companyName: company?.name ?? '—',
companyParentAgencyId: company?.parentAgencyId ?? null,
lastMessage: lastMsg ? { fromName: lastMsg.fromName, fromType: lastMsg.fromType, body: lastMsg.body.slice(0, 120), createdAt: lastMsg.createdAt } : null,
messageCount: t.messages.length,
sla: {
@@ -37,15 +47,36 @@ export async function GET() {
}
})
// --- Agency routing filter ---
// scope=agency + agencyId=X → tickets belonging to this agency's clients
// (NOT escalated to admin), plus tickets where agencyCompanyId === X.
// scope=admin → only tickets that reached us (no agency, or escalated).
if (scope === 'agency' && agencyId) {
enriched = enriched.filter((t) => {
const belongsToAgency = t.companyParentAgencyId === agencyId
const routedToThisAgency = t.agencyCompanyId === agencyId
const escalatedAway = t.escalatedToAdmin
return (belongsToAgency || routedToThisAgency) && !escalatedAway
})
} else if (scope === 'admin') {
// Admin sees: direct clients (no parent agency) + escalated tickets.
enriched = enriched.filter((t) => {
const isDirectClient = !t.companyParentAgencyId
const escalated = t.escalatedToAdmin
return isDirectClient || escalated
})
}
const stats = {
total: tickets.length,
open: tickets.filter((t) => t.status === 'open').length,
pending: tickets.filter((t) => t.status === 'pending').length,
resolved: tickets.filter((t) => t.status === 'resolved').length,
closed: tickets.filter((t) => t.status === 'closed').length,
urgent: tickets.filter((t) => t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved').length,
total: enriched.length,
open: enriched.filter((t) => t.status === 'open').length,
pending: enriched.filter((t) => t.status === 'pending').length,
resolved: enriched.filter((t) => t.status === 'resolved').length,
closed: enriched.filter((t) => t.status === 'closed').length,
urgent: enriched.filter((t) => t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved').length,
slaBreached: enriched.filter((t) => t.sla.responseBreached || t.sla.resolveBreached).length,
unresponded: tickets.filter((t) => !t.slaRespondedAt && t.status === 'open').length,
unresponded: enriched.filter((t) => !t.slaRespondedAt && t.status === 'open').length,
escalated: enriched.filter((t) => t.escalatedToAdmin).length,
}
return NextResponse.json({ tickets: enriched, stats })
@@ -54,3 +85,83 @@ export async function GET() {
return NextResponse.json({ error: msg }, { status: 500 })
}
}
const createSchema = z.object({
companyId: z.string().min(1),
subject: z.string().min(1).max(200),
categoryId: z.string().min(1),
priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'),
projectId: z.string().optional(),
licenseId: z.string().optional(),
message: z.string().min(1).max(8000),
fromName: z.string().min(1).max(100),
})
// POST /api/aos/tickets
// Create a support ticket. Auto-routes to the client's agency if the client
// belongs to one (routedToAgency=true); otherwise reaches admin directly.
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { companyId, subject, categoryId, priority, projectId, licenseId, message, fromName } = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// Determine routing: if the client belongs to an approved agency, route
// to that agency (routedToAgency=true, agencyCompanyId=parentAgencyId).
// Otherwise the ticket reaches us directly.
const agencyId = company.parentAgencyId
const routedToAgency = !!agencyId
// Gapless ticket number
const count = await db.ticket.count()
const year = new Date().getFullYear()
const number = `TKT-${year}-${String(count + 1).padStart(4, '0')}`
const ticket = await db.ticket.create({
data: {
number,
subject,
status: 'open',
priority,
categoryId,
companyId,
projectId: projectId ?? null,
licenseId: licenseId ?? null,
agencyCompanyId: agencyId,
routedToAgency,
escalatedToAdmin: false,
messages: {
create: {
fromType: 'customer',
fromName,
body: message,
},
},
},
include: { messages: true },
})
await db.auditLog.create({
data: {
actor: `client:${company.name}`,
action: routedToAgency ? 'ticket.create:routed-to-agency' : 'ticket.create:direct',
entity: 'Ticket',
entityId: ticket.id,
after: JSON.stringify({ number, subject, companyId, routedToAgency, agencyId }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ticket }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+198
View File
@@ -0,0 +1,198 @@
'use client'
/**
* FuseButton — destructive / consequential action confirmation.
*
* UX:
* 1. Press-and-hold the button. A progress bar fills over `holdMs` (default 700 ms).
* 2. Hold until the bar reaches 100 % → button enters the "primed" state.
* 3. Release the pointer (press-end). On release, `onCommit` fires.
* 4. If the pointer leaves the button or is released early, the action is cancelled
* and the fuse resets — no commit happens.
* 5. After commit, the button shows `doneLabel` for `undoMs` (default 5000 ms) and
* emits a Sonner toast with an optional "Rückgängig" action bound to `onUndo`.
* If `onUndo` is omitted the toast is a plain success message.
*
* Why: prevents accidental approvals / deletions. A single stray click cannot trigger
* the action — the user must deliberately hold and release.
*/
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Check, Loader2, Undo2 } from 'lucide-react'
type Phase = 'idle' | 'pressing' | 'primed' | 'committing' | 'done'
export interface FuseButtonProps
extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'onPointerDown' | 'onPointerUp' | 'onPointerLeave'> {
/** Initial label, shown when idle / pressing. */
label: string
/** Label shown after commit has fired. */
doneLabel: string
/** Fires on press-end after the hold threshold was reached. */
onCommit: () => Promise<void> | void
/** Optional undo handler. When provided, the success toast shows a "Rückgängig" action. */
onUndo?: () => Promise<void> | void
/** How long the user must hold before the fuse is primed (ms). */
holdMs?: number
/** How long the done-state + undo toast is shown (ms). */
undoMs?: number
}
export function FuseButton({
label,
doneLabel,
onCommit,
onUndo,
holdMs = 700,
undoMs = 5000,
disabled,
variant = 'default',
className,
children,
...rest
}: FuseButtonProps) {
const [phase, setPhase] = React.useState<Phase>('idle')
const [progress, setProgress] = React.useState(0)
const holdRef = React.useRef<ReturnType<typeof setInterval> | null>(null)
const startRef = React.useRef<number>(0)
const primedRef = React.useRef(false)
const clearHold = React.useCallback(() => {
if (holdRef.current) {
clearInterval(holdRef.current)
holdRef.current = null
}
}, [])
React.useEffect(() => {
return () => clearHold()
}, [clearHold])
// Reset done-state after undoMs so the button becomes usable again.
React.useEffect(() => {
if (phase !== 'done') return
const t = setTimeout(() => {
setPhase('idle')
setProgress(0)
primedRef.current = false
}, undoMs)
return () => clearTimeout(t)
}, [phase, undoMs])
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || phase === 'committing' || phase === 'done') return
e.preventDefault()
primedRef.current = false
setPhase('pressing')
setProgress(0)
startRef.current = Date.now()
clearHold()
holdRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current
const pct = Math.min(100, (elapsed / holdMs) * 100)
setProgress(pct)
if (pct >= 100 && !primedRef.current) {
primedRef.current = true
setPhase('primed')
}
}, 16)
}
const handlePointerUp = async () => {
if (phase === 'idle' || phase === 'done' || phase === 'committing') return
clearHold()
if (!primedRef.current) {
// Released too early — cancel.
setPhase('idle')
setProgress(0)
return
}
primedRef.current = false
setProgress(100)
setPhase('committing')
try {
await onCommit()
setPhase('done')
toast.success(doneLabel, {
duration: undoMs,
action: onUndo
? {
label: (
<>
<Undo2 className="size-3.5" />
Rückgängig
</>
),
onClick: async () => {
try {
await onUndo()
setPhase('idle')
setProgress(0)
toast.success('Aktion rückgängig gemacht')
} catch {
toast.error('Rückgängig fehlgeschlagen')
}
},
}
: undefined,
})
} catch {
setPhase('idle')
setProgress(0)
toast.error('Aktion fehlgeschlagen')
}
}
const handlePointerLeave = () => {
// Leaving the button while holding cancels the fuse.
if (phase === 'pressing' || phase === 'primed') {
clearHold()
primedRef.current = false
setPhase('idle')
setProgress(0)
}
}
const displayLabel = phase === 'done' ? doneLabel : label
const busy = phase === 'committing'
const holding = phase === 'pressing' || phase === 'primed'
return (
<Button
type="button"
variant={phase === 'primed' ? 'default' : variant}
disabled={disabled || busy}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
className={cn(
'relative overflow-hidden select-none touch-none',
holding && 'cursor-grabbing',
className,
)}
{...rest}
>
{/* Progress fill overlay */}
<span
aria-hidden
className={cn(
'pointer-events-none absolute inset-y-0 left-0 bg-foreground/15 transition-[width] duration-75',
phase === 'done' && 'bg-foreground/10',
)}
style={{ width: `${phase === 'done' ? 100 : progress}%` }}
/>
<span className="relative z-10 flex items-center gap-1.5">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : phase === 'done' ? (
<Check className="size-4" />
) : null}
{displayLabel}
{children}
</span>
</Button>
)
}
+151
View File
@@ -0,0 +1,151 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SlideCommitProps {
/** Triggered when the slider reaches the right edge (slide completes). */
onCommit: () => void | Promise<void>
/** Label shown inside the track behind the handle. */
label?: string
/** Disable the slider (e.g. while a request is in-flight). */
disabled?: boolean
/** Show a spinner inside the handle instead of the chevron. */
loading?: boolean
className?: string
}
/**
* SlideCommit — a "slide-to-confirm" button.
*
* Used for destructive / consequential actions (cancel subscription, delete,
* sign contract, etc.) to prevent accidental clicks. The user must press and
* drag the handle to the right edge; on release at >= 95 % the `onCommit`
* callback fires. Releasing earlier snaps back to the start with no commit.
*/
export default function SlideCommit({
onCommit,
label = 'Bitte nach rechts schieben',
disabled = false,
loading = false,
className,
}: SlideCommitProps) {
const trackRef = useRef<HTMLDivElement>(null)
const [progress, setProgress] = useState(0) // 0..1
const [dragging, setDragging] = useState(false)
const [committed, setCommitted] = useState(false)
const dragOrigin = useRef<{ startX: number; startProgress: number } | null>(null)
const handleWidth = 44 // px (matches w-11)
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || loading || committed) return
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
dragOrigin.current = { startX: e.clientX, startProgress: progress }
setDragging(true)
},
[disabled, loading, committed, progress]
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || !dragOrigin.current || !trackRef.current) return
const trackWidth = trackRef.current.clientWidth - handleWidth
const dx = e.clientX - dragOrigin.current.startX
const next = Math.min(1, Math.max(0, dragOrigin.current.startProgress + dx / trackWidth))
setProgress(next)
},
[dragging]
)
const onPointerUp = useCallback(async () => {
if (!dragging) return
setDragging(false)
dragOrigin.current = null
if (progress >= 0.95) {
setCommitted(true)
try {
await onCommit()
} finally {
// allow the parent to control re-render via loading; reset on commit
// only when not still loading.
if (!loading) {
setTimeout(() => {
setProgress(0)
setCommitted(false)
}, 600)
}
}
} else {
// snap back
setProgress(0)
}
}, [dragging, progress, onCommit, loading])
// Reset if the parent flips `loading` off after a commit
useEffect(() => {
if (!loading && committed) {
const t = setTimeout(() => {
setProgress(0)
setCommitted(false)
}, 400)
return () => clearTimeout(t)
}
}, [loading, committed])
return (
<div
ref={trackRef}
className={cn(
'relative flex h-11 w-full select-none items-center overflow-hidden rounded-md border border-border bg-muted/50',
disabled && 'opacity-50',
className
)}
>
{/* track label */}
<span
className={cn(
'pointer-events-none absolute inset-0 flex items-center justify-center text-xs font-medium text-muted-foreground transition-opacity',
progress > 0.1 && 'opacity-0'
)}
>
{label}
</span>
{/* progress fill (foreground-tinted) */}
<div
className="pointer-events-none absolute inset-y-0 left-0 bg-foreground/10"
style={{ width: `${progress * 100}%` }}
/>
{/* handle */}
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
role="slider"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
tabIndex={0}
className={cn(
'relative z-10 flex size-11 cursor-grab touch-none items-center justify-center rounded-md border border-foreground/40 bg-background shadow-sm transition-[box-shadow] active:cursor-grabbing',
committed && 'border-foreground/60 bg-foreground/5',
!dragging && 'transition-[left] duration-150 ease-out'
)}
style={{ left: `calc(${progress} * (100% - 44px))` }}
>
{loading ? (
<Loader2 className="size-4 animate-spin text-foreground" />
) : (
<ChevronRight className="size-4 text-foreground" />
)}
</div>
</div>
)
}
+2
View File
@@ -17,6 +17,7 @@ import { ProjectsView } from './views/projects'
import { TimesheetView } from './views/timesheet'
import { SupportView } from './views/support'
import { LicensesView } from './views/licenses'
import { AgencyView } from './views/agency'
import { ContractsView } from './views/contracts'
import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance'
@@ -71,6 +72,7 @@ export function AppShell() {
{view === 'timesheet' && <TimesheetView />}
{view === 'support' && <SupportView />}
{view === 'licenses' && <LicensesView />}
{view === 'agency' && <AgencyView />}
{view === 'contracts' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
{view === 'finance' && <FinanceView />}
+1
View File
@@ -49,6 +49,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
+1
View File
@@ -67,6 +67,7 @@ const NAV: {
group: 'nav.operations',
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
+1
View File
@@ -28,6 +28,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' },
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
+875
View File
@@ -0,0 +1,875 @@
'use client'
import { useMemo, useState } from 'react'
import {
Users,
Building2,
Globe,
MapPin,
Clock,
Percent,
CheckCircle2,
XCircle,
History,
Inbox,
Mail,
Loader2,
ChevronRight,
ShoppingBag,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { PageHeader } from '../primitives'
import { FuseButton } from '@/components/FuseButton'
import { formatRelative, formatDate, formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
/* ----------------------------- types ----------------------------- */
interface AgencyCompany {
id: string
name: string
email: string | null
website: string | null
city: string | null
isAgency: boolean
agencyStatus: string | null
agencyDiscountPct: number | null
}
interface AgencyApplication {
id: string
companyId: string
status: 'pending' | 'approved' | 'rejected'
discountPct: number
businessModel?: string | null
expectedClientVolume?: string | null
referredBy?: string | null
reviewedBy?: string | null
reviewedAt?: string | null
rejectReason?: string | null
appliedAt: string
createdAt: string
updatedAt: string
company: AgencyCompany
}
interface AgencyClient {
id: string
name: string
email: string | null
}
interface ApprovedAgency extends AgencyCompany {
agencyDiscountPct: number
agencyApprovedAt: string
agencyClients: AgencyClient[]
_count: { agencyClients: number; licenses: number }
}
interface AgencyData {
applications: {
pending: AgencyApplication[]
reviewed: AgencyApplication[]
}
agencies: ApprovedAgency[]
stats: { pending: number; approved: number; totalApplications: number }
}
const BASE_PRICE_EUR = 50 // 50 €/Monat base price for preview
/* ----------------------------- view ----------------------------- */
export function AgencyView() {
const { data, loading, refetch } = useFetch<AgencyData>('/api/aos/agency')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64" />
</div>
)
}
const { applications, agencies, stats } = data
const avgDiscount =
agencies.length > 0
? Math.round(
agencies.reduce((sum, a) => sum + (a.agencyDiscountPct ?? 0), 0) / agencies.length,
)
: 0
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Bayis"
description="Reseller-Verwaltung: Bayi-Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Bayis mit ihren Kunden einsehen."
/>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard
label="Offene Bewerbungen"
value={stats.pending}
icon={Inbox}
hint="wartet auf Freigabe"
/>
<StatCard
label="Genehmigte Bayis"
value={stats.approved}
icon={Users}
hint="aktiv im System"
/>
<StatCard
label="Bewerbungen gesamt"
value={stats.totalApplications}
icon={History}
hint="inkl. Verlauf"
/>
<StatCard
label="Ø Rabatt"
value={`${avgDiscount}%`}
icon={Percent}
hint={agencies.length ? `über ${agencies.length} Bayis` : '—'}
/>
</div>
<PendingApplicationsSection applications={applications.pending} onChanged={refetch} />
<ApprovedAgenciesSection agencies={agencies} />
<ReviewedSection applications={applications.reviewed} />
</div>
)
}
/* ----------------------------- stats card ----------------------------- */
function StatCard({
label,
value,
icon: Icon,
hint,
}: {
label: string
value: number | string
icon: typeof Inbox
hint?: string
}) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{label}</p>
<Icon className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
</Card>
)
}
/* ----------------------------- pending applications ----------------------------- */
function PendingApplicationsSection({
applications,
onChanged,
}: {
applications: AgencyApplication[]
onChanged: () => void
}) {
const [approveTarget, setApproveTarget] = useState<AgencyApplication | null>(null)
const [rejectTarget, setRejectTarget] = useState<AgencyApplication | null>(null)
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold tracking-tight">
Bewerbende Bayis
<span className="ml-2 text-xs font-normal text-muted-foreground">
{applications.length} offen
</span>
</h2>
</div>
{applications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
<p className="max-w-sm text-xs text-muted-foreground">
Neue Bayi-Bewerbungen erscheinen hier.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{applications.map((app) => (
<Card key={app.id} className="flex flex-col">
<CardContent className="flex flex-1 flex-col gap-3 p-4">
{/* header */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-semibold">{app.company.name}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
{app.company.email && (
<span className="inline-flex items-center gap-1">
<Mail className="size-3" />
{app.company.email}
</span>
)}
{app.company.website && (
<span className="inline-flex items-center gap-1">
<Globe className="size-3" />
{app.company.website}
</span>
)}
{app.company.city && (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
{app.company.city}
</span>
)}
</div>
</div>
<Badge variant="outline" className="gap-1 border-foreground/40 text-[10px]">
<Clock className="size-2.5" />
{formatRelative(app.appliedAt)}
</Badge>
</div>
{/* application details */}
{(app.businessModel || app.expectedClientVolume || app.referredBy) && (
<div className="grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-3">
{app.businessModel && (
<Meta label="Geschäftsmodell" value={app.businessModel} />
)}
{app.expectedClientVolume && (
<Meta label="Erw. Kundenvolumen" value={app.expectedClientVolume} />
)}
{app.referredBy && (
<Meta label="Empfohlen von" value={app.referredBy} />
)}
</div>
)}
{/* actions */}
<div className="mt-auto flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="outline"
onClick={() => setRejectTarget(app)}
>
<XCircle className="size-3.5" />
Ablehnen
</Button>
<Button
size="sm"
onClick={() => setApproveTarget(app)}
>
<CheckCircle2 className="size-3.5" />
Genehmigen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<ApproveDialog
application={approveTarget}
onClose={() => setApproveTarget(null)}
onChanged={onChanged}
/>
<RejectDialog
application={rejectTarget}
onClose={() => setRejectTarget(null)}
onChanged={onChanged}
/>
</section>
)
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-xs font-medium">{value}</p>
</div>
)
}
/* ----------------------------- approve dialog ----------------------------- */
function ApproveDialog({
application,
onClose,
onChanged,
}: {
application: AgencyApplication | null
onClose: () => void
onChanged: () => void
}) {
const [discount, setDiscount] = useState(10)
const [committing, setCommitting] = useState(false)
// Reset state when dialog opens for a new application
const handleOpenChange = (open: boolean) => {
if (open) setDiscount(10)
if (!open) {
if (!committing) onClose()
}
}
const net = useMemo(() => {
const netEur = BASE_PRICE_EUR * (1 - discount / 100)
return netEur.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}, [discount])
const handleCommit = async () => {
if (!application) return
setCommitting(true)
try {
const r = await fetch(`/api/aos/agency/${application.id}/approve`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ discountPct: discount }),
})
if (!r.ok) {
const err = await r.json().catch(() => ({}))
throw new Error(err.error || 'approve failed')
}
// Defer close + refetch so the FuseButton's done-state + toast can render.
setTimeout(() => {
onClose()
onChanged()
}, 350)
} catch (err) {
setCommitting(false)
throw err
}
// Note: setCommitting(false) intentionally skipped on success — dialog will
// close via the setTimeout above and the button's doneLabel will be shown.
}
return (
<Dialog open={!!application} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
{application && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="size-4" />
Bayi-Bewerbung genehmigen
</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{application.company.name}</span> wird
als Bayi freigeschaltet. Der Rabatt wird auf alle vom Bayi angelegten Kundenlizenzierungen
angewendet.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="discount-pct">Rabatt (%)</Label>
<span className="text-[10px] text-muted-foreground">0 – 20</span>
</div>
<Input
id="discount-pct"
type="number"
min={0}
max={20}
step={1}
value={discount}
onChange={(e) => {
const raw = parseInt(e.target.value, 10)
if (Number.isNaN(raw)) {
setDiscount(0)
return
}
setDiscount(Math.min(20, Math.max(0, raw)))
}}
className="tabular-nums"
/>
</div>
{/* live preview */}
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
Preisvorschau
</p>
<p className="mt-1 tabular-nums">
<span className="font-medium">{BASE_PRICE_EUR} €/Monat</span>
<span className="mx-1.5 text-muted-foreground">Basis</span>
<span className="mx-1 text-muted-foreground">→</span>
<span className="text-muted-foreground">mit {discount}% Rabatt</span>
<span className="mx-1.5 text-muted-foreground">=</span>
<span className="font-semibold border-foreground/40 px-1.5 py-0.5 rounded border">
{net} €
</span>
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={committing}>
Abbrechen
</Button>
<FuseButton
label="Genehmigen"
doneLabel="Genehmigt"
onCommit={handleCommit}
disabled={committing}
variant="default"
/>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
/* ----------------------------- reject dialog ----------------------------- */
function RejectDialog({
application,
onClose,
onChanged,
}: {
application: AgencyApplication | null
onClose: () => void
onChanged: () => void
}) {
const [reason, setReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleOpenChange = (open: boolean) => {
if (!open) {
if (!submitting) {
setReason('')
onClose()
}
}
}
const submit = async () => {
if (!application) return
if (!reason.trim()) {
toast.error('Bitte gib einen Ablehnungsgrund an.')
return
}
setSubmitting(true)
const t = toast.loading('Ablehnung wird gespeichert…')
try {
const r = await fetch(`/api/aos/agency/${application.id}/reject`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ reason: reason.trim() }),
})
if (!r.ok) {
const err = await r.json().catch(() => ({}))
throw new Error(err.error || 'reject failed')
}
toast.success(`${application.company.name} abgelehnt`, { id: t })
setReason('')
onClose()
onChanged()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Ablehnung fehlgeschlagen', { id: t })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={!!application} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
{application && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<XCircle className="size-4" />
Bayi-Bewerbung ablehnen
</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{application.company.name}</span>
{' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="reject-reason">Ablehnungsgrund</Label>
<Textarea
id="reject-reason"
placeholder="z. B. Unvollständige Angaben, kein passendes Geschäftsmodell…"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
maxLength={500}
disabled={submitting}
/>
<p className="text-right text-[10px] text-muted-foreground">
{reason.length} / 500
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={submitting}>
Abbrechen
</Button>
<Button variant="destructive" onClick={submit} disabled={submitting}>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<XCircle className="size-3.5" />
)}
Ablehnen
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
/* ----------------------------- approved agencies ----------------------------- */
function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
const [openAgency, setOpenAgency] = useState<ApprovedAgency | null>(null)
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold tracking-tight">
Genehmigte Bayis
<span className="ml-2 text-xs font-normal text-muted-foreground">
{agencies.length} aktiv
</span>
</h2>
</div>
{agencies.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
<Users className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Noch keine Bayis genehmigt</p>
<p className="max-w-sm text-xs text-muted-foreground">
Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
</p>
</div>
</div>
) : (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Bayis ({agencies.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<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">
<tr>
<th className="px-4 py-2 font-medium">Bayi</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">Lizenzen</th>
<th className="px-4 py-2 font-medium">Genehmigt am</th>
<th className="px-4 py-2 font-medium text-right">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{agencies.map((a) => (
<tr key={a.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border">
<Building2 className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium">{a.name}</p>
{a.email && (
<p className="truncate text-[10px] text-muted-foreground">{a.email}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5">
<Badge
variant="outline"
className="border-foreground/40 text-[10px] tabular-nums"
>
<Percent className="size-2.5" />
{a.agencyDiscountPct}%
</Badge>
</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">
{a._count.agencyClients}
</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">
{a._count.licenses}
</td>
<td className="px-4 py-2.5 text-xs">
{formatDate(a.agencyApprovedAt)}
</td>
<td className="px-4 py-2.5 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => setOpenAgency(a)}
className="gap-1"
>
<ShoppingBag className="size-3.5" />
Kunden ansehen
<ChevronRight className="size-3" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
<ClientsSheet agency={openAgency} onClose={() => setOpenAgency(null)} />
</section>
)
}
function ClientsSheet({
agency,
onClose,
}: {
agency: ApprovedAgency | null
onClose: () => void
}) {
return (
<Sheet open={!!agency} onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
{agency && (
<>
<SheetHeader className="border-b border-border">
<SheetTitle className="flex items-center gap-2">
<Building2 className="size-4 text-muted-foreground" />
{agency.name}
</SheetTitle>
<SheetDescription>
Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
{agency._count.licenses} Lizenz(en).
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-3">
{/* routing note */}
<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" />
<p className="text-muted-foreground">
Bayi kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
an den Bayi geroutet, nicht an uns.
</p>
</div>
{/* meta */}
<dl className="mb-3 grid grid-cols-2 gap-2 text-xs">
<MetaItem label="Rabatt" value={`${agency.agencyDiscountPct}%`} />
<MetaItem label="Genehmigt" value={formatDate(agency.agencyApprovedAt)} />
{agency.email && (
<MetaItem label="E-Mail" value={agency.email} full />
)}
{agency.city && <MetaItem label="Stadt" value={agency.city} />}
{agency.website && <MetaItem label="Website" value={agency.website} />}
</dl>
{/* clients list */}
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Kunden ({agency.agencyClients.length})
</p>
{agency.agencyClients.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<p className="text-xs text-muted-foreground">
Noch keine Kunden angelegt.
</p>
</div>
) : (
<ul className="space-y-1">
{agency.agencyClients.map((c) => (
<li
key={c.id}
className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2"
>
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{c.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{c.name}</p>
{c.email && (
<p className="truncate text-[10px] text-muted-foreground">{c.email}</p>
)}
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
function MetaItem({
label,
value,
full,
}: {
label: string
value: string
full?: boolean
}) {
return (
<div className={cn('min-w-0', full && 'col-span-2')}>
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-xs font-medium">{value}</dd>
</div>
)
}
/* ----------------------------- reviewed (history) ----------------------------- */
function ReviewedSection({ applications }: { applications: AgencyApplication[] }) {
const [open, setOpen] = useState(false)
return (
<section className="space-y-2">
<Collapsible open={open} onOpenChange={setOpen}>
<Card className="bg-muted/30">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<History className="size-3.5 text-muted-foreground" />
<span className="text-xs font-semibold">Verlauf</span>
<Badge variant="secondary" className="text-[10px] tabular-nums">
{applications.length}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground">
{open ? 'ausblenden' : 'einblenden'}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{applications.length === 0 ? (
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
Noch keine bewerteten Bewerbungen.
</div>
) : (
<div className="border-t border-border">
<ul className="divide-y divide-border">
{applications.map((a) => (
<li
key={a.id}
className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium">{a.company.name}</span>
<StatusBadge status={a.status} />
</div>
{a.status === 'rejected' && a.rejectReason && (
<p className="truncate text-[11px] text-muted-foreground">
Grund: {a.rejectReason}
</p>
)}
{a.status === 'approved' && (
<p className="text-[11px] text-muted-foreground">
Rabatt: <span className="tabular-nums">{a.discountPct}%</span>
</p>
)}
</div>
<div className="shrink-0 text-right text-[10px] text-muted-foreground">
{a.reviewedBy && <p>reviewed by {a.reviewedBy}</p>}
{a.reviewedAt && (
<p className="tabular-nums">{formatDateTime(a.reviewedAt)}</p>
)}
</div>
</li>
))}
</ul>
</div>
)}
</CollapsibleContent>
</Card>
</Collapsible>
</section>
)
}
function StatusBadge({ status }: { status: 'approved' | 'rejected' | 'pending' }) {
if (status === 'approved') {
return (
<Badge
variant="outline"
className="gap-1 border-foreground/40 text-[10px]"
>
<CheckCircle2 className="size-2.5" />
Genehmigt
</Badge>
)
}
if (status === 'rejected') {
return (
<Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
<XCircle className="size-2.5" />
Abgelehnt
</Badge>
)
}
return (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
Offen
</Badge>
)
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -28,6 +28,7 @@ const de: Dict = {
'nav.projects': 'Projekte',
'nav.support': 'Support',
'nav.licenses': 'Lizenzen',
'nav.agency': 'Bayis',
'nav.contracts': 'Verträge',
'nav.expenses': 'Ausgaben',
'nav.finance': 'Finanzen',
@@ -84,6 +85,7 @@ const en: Dict = {
'nav.projects': 'Projects',
'nav.support': 'Support',
'nav.licenses': 'Licenses',
'nav.agency': 'Resellers (Bayis)',
'nav.contracts': 'Contracts',
'nav.expenses': 'Expenses',
'nav.finance': 'Finance',
@@ -136,6 +138,7 @@ const tr: Dict = {
'nav.projects': 'Projeler',
'nav.support': 'Destek',
'nav.licenses': 'Lisanslar',
'nav.agency': 'Bayiler',
'nav.contracts': 'Sözleşmeler',
'nav.expenses': 'Giderler',
'nav.finance': 'Finans',
Regular → Executable
View File
File mode changed.
+1
View File
@@ -13,6 +13,7 @@ export type ViewId =
| 'timesheet'
| 'support'
| 'licenses'
| 'agency'
| 'contracts'
| 'expenses'
| 'finance'