From 762f3d73730b7e607c5230fd40b94901d33220e4 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 15:39:30 +0000 Subject: [PATCH] a6c0f3de-eeb0-4d76-a57f-f600dd133c06 --- .env | 38 + .zscripts/dev.pid | 0 aos-shots/01-dashboard.png | Bin aos-shots/02-crm.png | Bin aos-shots/03-leads.png | Bin aos-shots/04-projects.png | Bin aos-shots/05-licenses.png | Bin aos-shots/06-finance.png | Bin aos-shots/07-settings.png | Bin aos-shots/08-lead-explain.png | Bin aos-shots/09-mobile-dashboard.png | Bin aos-shots/10-mobile-crm.png | Bin aos-shots/v10-estimate-detail.png | Bin aos-shots/v10-estimates.png | Bin aos-shots/v10-support-summarized.png | Bin aos-shots/v10-support.png | Bin aos-shots/v11-reporting-mrr.png | Bin aos-shots/v11-reporting-utilization.png | Bin aos-shots/v11-reporting.png | Bin aos-shots/v11-wizard-step1.png | Bin aos-shots/v11-wizard-step3-modules.png | Bin aos-shots/v11-wizard-step4-mail.png | Bin aos-shots/v12-wizard.png | Bin aos-shots/v13-accounting-bank.png | Bin aos-shots/v13-accounting-dunning.png | Bin aos-shots/v13-accounting-eur.png | Bin aos-shots/v13-accounting-vat.png | Bin aos-shots/v14-mail-real-test.png | Bin aos-shots/v14-vat-real-xml.png | Bin aos-shots/v15-api-docs.png | Bin aos-shots/v15-api-tokens.png | Bin aos-shots/v16-portal-approval-dialog.png | Bin aos-shots/v16-portal-approval-done.png | Bin aos-shots/v16-portal-approvals.png | Bin aos-shots/v16-portal-invoices.png | Bin aos-shots/v16-portal-login.png | Bin aos-shots/v16-portal-overview.png | Bin aos-shots/v17-de.png | Bin aos-shots/v17-debug1.png | Bin aos-shots/v17-dropdown-open.png | Bin aos-shots/v17-en-final.png | Bin aos-shots/v17-en-persisted.png | Bin aos-shots/v17-en-real-final.png | Bin aos-shots/v17-en-real.png | Bin aos-shots/v17-en-verified.png | Bin aos-shots/v17-en.png | Bin aos-shots/v17-locale-dropdown.png | Bin aos-shots/v17-settings.png | Bin aos-shots/v17-tr-persisted.png | Bin aos-shots/v17-tr.png | Bin aos-shots/v18-kb-article.png | Bin aos-shots/v18-kb.png | Bin aos-shots/v18-notifications.png | Bin aos-shots/v19-calendar-next.png | Bin aos-shots/v19-calendar.png | Bin aos-shots/v2-CRM.png | Bin aos-shots/v2-Dashboard.png | Bin aos-shots/v2-Erweiterungen.png | Bin aos-shots/v2-Finanzen.png | Bin aos-shots/v2-Lizenzen.png | Bin aos-shots/v2-Projekte.png | Bin aos-shots/v2-ai-explain.png | Bin aos-shots/v2-leads-initial.png | Bin aos-shots/v20-onboarding-dismissed.png | Bin aos-shots/v20-onboarding-last.png | Bin aos-shots/v20-onboarding-step1.png | Bin aos-shots/v20-onboarding-step2.png | Bin aos-shots/v21-dark-crm.png | Bin aos-shots/v21-dark-dashboard.png | Bin aos-shots/v21-dark-finance.png | Bin aos-shots/v21-dark-portal.png | Bin aos-shots/v22-mobile-calendar.png | Bin aos-shots/v22-mobile-dashboard.png | Bin aos-shots/v22-mobile-leads.png | Bin aos-shots/v22-mobile-menu.png | Bin aos-shots/v22-mobile-projects.png | Bin aos-shots/v22-mobile-search.png | Bin aos-shots/v3-audit.png | Bin aos-shots/v3-company-drawer.png | Bin aos-shots/v3-invoice-dialog.png | Bin aos-shots/v3-projects-task.png | Bin aos-shots/v4-audit.png | Bin aos-shots/v4-company-drawer.png | Bin aos-shots/v4-create-deal.png | Bin aos-shots/v4-invoice-dialog.png | Bin aos-shots/v4-lead-approved.png | Bin aos-shots/v4-lead-converted.png | Bin aos-shots/v4-projects-task.png | Bin aos-shots/v4-projects-time.png | Bin aos-shots/v5-company-drawer.png | Bin aos-shots/v6-audit-detail.png | Bin aos-shots/v6-audit.png | Bin aos-shots/v6-command-search.png | Bin aos-shots/v6-company-drawer.png | Bin aos-shots/v6-create-deal-done.png | Bin aos-shots/v6-create-deal-filled.png | Bin aos-shots/v6-create-deal.png | Bin aos-shots/v6-invoice-dialog.png | Bin aos-shots/v6-lead-approved.png | Bin aos-shots/v6-lead-converted.png | Bin aos-shots/v6-projects-task.png | Bin aos-shots/v6-projects-time.png | Bin aos-shots/v7-palette-open.png | Bin aos-shots/v7-search-results.png | Bin aos-shots/v8-search-bauer.png | Bin aos-shots/v9-contract-detail.png | Bin aos-shots/v9-contracts.png | Bin aos-shots/v9-expenses-report.png | Bin aos-shots/v9-expenses.png | Bin bun.lock | 0 db/custom.db | Bin 466944 -> 491520 bytes package.json | 3 +- prisma/schema.prisma | 114 +- src/app/api/aos/agency/[id]/approve/route.ts | 90 ++ src/app/api/aos/agency/[id]/reject/route.ts | 68 ++ src/app/api/aos/agency/apply/route.ts | 80 ++ src/app/api/aos/agency/route.ts | 42 + src/app/api/aos/clients/route.ts | 87 ++ src/app/api/aos/crm/mail-sync/route.ts | 0 src/app/api/aos/leads/discover/route.ts | 0 src/app/api/aos/licenses/[id]/cancel/route.ts | 120 +++ .../api/aos/licenses/[id]/promote/route.ts | 62 ++ .../api/aos/licenses/[id]/suspend/route.ts | 58 + src/app/api/aos/licenses/check/route.ts | 213 ++++ src/app/api/aos/licenses/route.ts | 266 ++++- src/app/api/aos/licenses/validate/route.ts | 0 .../api/aos/tickets/[id]/escalate/route.ts | 45 + src/app/api/aos/tickets/route.ts | 137 ++- src/components/FuseButton.tsx | 198 ++++ src/components/SlideCommit.tsx | 151 +++ src/components/aos/app-shell.tsx | 2 + src/components/aos/command-menu.tsx | 1 + src/components/aos/sidebar-nav.tsx | 1 + src/components/aos/top-bar.tsx | 1 + src/components/aos/views/agency.tsx | 875 +++++++++++++++ src/components/aos/views/licenses.tsx | 995 +++++++++++++++++- src/lib/i18n.ts | 3 + src/lib/storage.ts | 0 src/store/aos.ts | 1 + .../bash_1793201275022_0839a27364a0.txt | 8 + .../read_1793199730058_7543b5fad96f.txt | 895 ++++++++++++++++ 141 files changed, 4485 insertions(+), 69 deletions(-) mode change 100644 => 100755 .zscripts/dev.pid mode change 100644 => 100755 aos-shots/01-dashboard.png mode change 100644 => 100755 aos-shots/02-crm.png mode change 100644 => 100755 aos-shots/03-leads.png mode change 100644 => 100755 aos-shots/04-projects.png mode change 100644 => 100755 aos-shots/05-licenses.png mode change 100644 => 100755 aos-shots/06-finance.png mode change 100644 => 100755 aos-shots/07-settings.png mode change 100644 => 100755 aos-shots/08-lead-explain.png mode change 100644 => 100755 aos-shots/09-mobile-dashboard.png mode change 100644 => 100755 aos-shots/10-mobile-crm.png mode change 100644 => 100755 aos-shots/v10-estimate-detail.png mode change 100644 => 100755 aos-shots/v10-estimates.png mode change 100644 => 100755 aos-shots/v10-support-summarized.png mode change 100644 => 100755 aos-shots/v10-support.png mode change 100644 => 100755 aos-shots/v11-reporting-mrr.png mode change 100644 => 100755 aos-shots/v11-reporting-utilization.png mode change 100644 => 100755 aos-shots/v11-reporting.png mode change 100644 => 100755 aos-shots/v11-wizard-step1.png mode change 100644 => 100755 aos-shots/v11-wizard-step3-modules.png mode change 100644 => 100755 aos-shots/v11-wizard-step4-mail.png mode change 100644 => 100755 aos-shots/v12-wizard.png mode change 100644 => 100755 aos-shots/v13-accounting-bank.png mode change 100644 => 100755 aos-shots/v13-accounting-dunning.png mode change 100644 => 100755 aos-shots/v13-accounting-eur.png mode change 100644 => 100755 aos-shots/v13-accounting-vat.png mode change 100644 => 100755 aos-shots/v14-mail-real-test.png mode change 100644 => 100755 aos-shots/v14-vat-real-xml.png mode change 100644 => 100755 aos-shots/v15-api-docs.png mode change 100644 => 100755 aos-shots/v15-api-tokens.png mode change 100644 => 100755 aos-shots/v16-portal-approval-dialog.png mode change 100644 => 100755 aos-shots/v16-portal-approval-done.png mode change 100644 => 100755 aos-shots/v16-portal-approvals.png mode change 100644 => 100755 aos-shots/v16-portal-invoices.png mode change 100644 => 100755 aos-shots/v16-portal-login.png mode change 100644 => 100755 aos-shots/v16-portal-overview.png mode change 100644 => 100755 aos-shots/v17-de.png mode change 100644 => 100755 aos-shots/v17-debug1.png mode change 100644 => 100755 aos-shots/v17-dropdown-open.png mode change 100644 => 100755 aos-shots/v17-en-final.png mode change 100644 => 100755 aos-shots/v17-en-persisted.png mode change 100644 => 100755 aos-shots/v17-en-real-final.png mode change 100644 => 100755 aos-shots/v17-en-real.png mode change 100644 => 100755 aos-shots/v17-en-verified.png mode change 100644 => 100755 aos-shots/v17-en.png mode change 100644 => 100755 aos-shots/v17-locale-dropdown.png mode change 100644 => 100755 aos-shots/v17-settings.png mode change 100644 => 100755 aos-shots/v17-tr-persisted.png mode change 100644 => 100755 aos-shots/v17-tr.png mode change 100644 => 100755 aos-shots/v18-kb-article.png mode change 100644 => 100755 aos-shots/v18-kb.png mode change 100644 => 100755 aos-shots/v18-notifications.png mode change 100644 => 100755 aos-shots/v19-calendar-next.png mode change 100644 => 100755 aos-shots/v19-calendar.png mode change 100644 => 100755 aos-shots/v2-CRM.png mode change 100644 => 100755 aos-shots/v2-Dashboard.png mode change 100644 => 100755 aos-shots/v2-Erweiterungen.png mode change 100644 => 100755 aos-shots/v2-Finanzen.png mode change 100644 => 100755 aos-shots/v2-Lizenzen.png mode change 100644 => 100755 aos-shots/v2-Projekte.png mode change 100644 => 100755 aos-shots/v2-ai-explain.png mode change 100644 => 100755 aos-shots/v2-leads-initial.png mode change 100644 => 100755 aos-shots/v20-onboarding-dismissed.png mode change 100644 => 100755 aos-shots/v20-onboarding-last.png mode change 100644 => 100755 aos-shots/v20-onboarding-step1.png mode change 100644 => 100755 aos-shots/v20-onboarding-step2.png mode change 100644 => 100755 aos-shots/v21-dark-crm.png mode change 100644 => 100755 aos-shots/v21-dark-dashboard.png mode change 100644 => 100755 aos-shots/v21-dark-finance.png mode change 100644 => 100755 aos-shots/v21-dark-portal.png mode change 100644 => 100755 aos-shots/v22-mobile-calendar.png mode change 100644 => 100755 aos-shots/v22-mobile-dashboard.png mode change 100644 => 100755 aos-shots/v22-mobile-leads.png mode change 100644 => 100755 aos-shots/v22-mobile-menu.png mode change 100644 => 100755 aos-shots/v22-mobile-projects.png mode change 100644 => 100755 aos-shots/v22-mobile-search.png mode change 100644 => 100755 aos-shots/v3-audit.png mode change 100644 => 100755 aos-shots/v3-company-drawer.png mode change 100644 => 100755 aos-shots/v3-invoice-dialog.png mode change 100644 => 100755 aos-shots/v3-projects-task.png mode change 100644 => 100755 aos-shots/v4-audit.png mode change 100644 => 100755 aos-shots/v4-company-drawer.png mode change 100644 => 100755 aos-shots/v4-create-deal.png mode change 100644 => 100755 aos-shots/v4-invoice-dialog.png mode change 100644 => 100755 aos-shots/v4-lead-approved.png mode change 100644 => 100755 aos-shots/v4-lead-converted.png mode change 100644 => 100755 aos-shots/v4-projects-task.png mode change 100644 => 100755 aos-shots/v4-projects-time.png mode change 100644 => 100755 aos-shots/v5-company-drawer.png mode change 100644 => 100755 aos-shots/v6-audit-detail.png mode change 100644 => 100755 aos-shots/v6-audit.png mode change 100644 => 100755 aos-shots/v6-command-search.png mode change 100644 => 100755 aos-shots/v6-company-drawer.png mode change 100644 => 100755 aos-shots/v6-create-deal-done.png mode change 100644 => 100755 aos-shots/v6-create-deal-filled.png mode change 100644 => 100755 aos-shots/v6-create-deal.png mode change 100644 => 100755 aos-shots/v6-invoice-dialog.png mode change 100644 => 100755 aos-shots/v6-lead-approved.png mode change 100644 => 100755 aos-shots/v6-lead-converted.png mode change 100644 => 100755 aos-shots/v6-projects-task.png mode change 100644 => 100755 aos-shots/v6-projects-time.png mode change 100644 => 100755 aos-shots/v7-palette-open.png mode change 100644 => 100755 aos-shots/v7-search-results.png mode change 100644 => 100755 aos-shots/v8-search-bauer.png mode change 100644 => 100755 aos-shots/v9-contract-detail.png mode change 100644 => 100755 aos-shots/v9-contracts.png mode change 100644 => 100755 aos-shots/v9-expenses-report.png mode change 100644 => 100755 aos-shots/v9-expenses.png mode change 100644 => 100755 bun.lock create mode 100644 src/app/api/aos/agency/[id]/approve/route.ts create mode 100644 src/app/api/aos/agency/[id]/reject/route.ts create mode 100644 src/app/api/aos/agency/apply/route.ts create mode 100644 src/app/api/aos/agency/route.ts create mode 100644 src/app/api/aos/clients/route.ts mode change 100644 => 100755 src/app/api/aos/crm/mail-sync/route.ts mode change 100644 => 100755 src/app/api/aos/leads/discover/route.ts create mode 100644 src/app/api/aos/licenses/[id]/cancel/route.ts create mode 100644 src/app/api/aos/licenses/[id]/promote/route.ts create mode 100644 src/app/api/aos/licenses/[id]/suspend/route.ts create mode 100644 src/app/api/aos/licenses/check/route.ts mode change 100644 => 100755 src/app/api/aos/licenses/validate/route.ts create mode 100644 src/app/api/aos/tickets/[id]/escalate/route.ts create mode 100644 src/components/FuseButton.tsx create mode 100644 src/components/SlideCommit.tsx create mode 100644 src/components/aos/views/agency.tsx mode change 100644 => 100755 src/lib/storage.ts create mode 100644 tool-results/bash_1793201275022_0839a27364a0.txt create mode 100644 tool-results/read_1793199730058_7543b5fad96f.txt diff --git a/.env b/.env index 769ebf2..7f9ca6d 100755 --- a/.env +++ b/.env @@ -1 +1,39 @@ +# AOS — Agency Operating System Environment (dev) + +# --- Database --- DATABASE_URL=file:/home/z/my-project/db/custom.db + +# --- Encryption (AES-256-GCM for secrets at rest) --- +ENCRYPTION_KEY=UrEE757ABMzqcME/AQfef9XDPSaBlgIhVDOy8BIpbSY= + +# --- Auth --- +AUTH_SECRET=zB7wxxn7xDnClPUlwoKG4VwHDaIqvtAz3DW3Bn43GlM= + +# --- Leads Finder Providers --- +TAVILY_API_KEY=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 +FIRECRAWL_API_KEY=fc-2ddabe84274a44b39da31a49d1624700 +SERPAPI_API_KEY=5e1d045575cc29b705c53e8303b6ada506e8a3ef382fca78a2964c7e25751ba1 +APIFY_API_TOKEN=apify_api_YUsr1w7L0qFpvzwDirjv1oWHToT2xu3lhJt7 + +# --- App --- +NODE_ENV=development +PORT=3000 + +# ── Stripe ── +STRIPE_PUBLISHABLE_KEY=pk_test_51TWfTxDGcQIOgPxeHVZh5ilWEc61bK7MBuBez4fGV5XrjRGwRXsXJp5rm1eAsWHGzQ20NBXRG7Nmp0pJRJfUflImA003OoDaSQt +STRIPE_SECRET_KEY=sk_test_51TWfTxDGcQIOgPxej8AvcfLMt6a7IEgZk3L34WLnOCNw2AAAyqORQtgJLjEaj3Ygm014ZAo3oByvDSdJVFwYLHmk0048mdLZH4 +RESTRICTED_KEY=rk_test_51TWfTxDGcQIOgPxeGPSfA6ske0A6xPde23jfOJKbUfFOrEWl4bTIHv4MSOBwlQpravXk4mxKAjKdmkGb3asZqOVJ0048mdLZH4 + +# ── License Server (B2B SaaS) ── +LICENSE_PRICE_STARTER_CENTS=2900 +LICENSE_PRICE_PRO_CENTS=5000 +LICENSE_PRICE_ENTERPRISE_CENTS=15000 +LICENSE_TRIAL_DAYS=14 +LICENSE_NOTICE_PERIOD_DAYS=90 +LICENSE_YEARLY_DISCOUNT_PCT=10 +LICENSE_OFFLINE_GRACE_HOURS=72 + +# ── Agency / Reseller ── +AGENCY_DISCOUNT_MIN_PCT=0 +AGENCY_DISCOUNT_MAX_PCT=20 +AGENCY_DISCOUNT_DEFAULT_PCT=10 diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid old mode 100644 new mode 100755 diff --git a/aos-shots/01-dashboard.png b/aos-shots/01-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/02-crm.png b/aos-shots/02-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/03-leads.png b/aos-shots/03-leads.png old mode 100644 new mode 100755 diff --git a/aos-shots/04-projects.png b/aos-shots/04-projects.png old mode 100644 new mode 100755 diff --git a/aos-shots/05-licenses.png b/aos-shots/05-licenses.png old mode 100644 new mode 100755 diff --git a/aos-shots/06-finance.png b/aos-shots/06-finance.png old mode 100644 new mode 100755 diff --git a/aos-shots/07-settings.png b/aos-shots/07-settings.png old mode 100644 new mode 100755 diff --git a/aos-shots/08-lead-explain.png b/aos-shots/08-lead-explain.png old mode 100644 new mode 100755 diff --git a/aos-shots/09-mobile-dashboard.png b/aos-shots/09-mobile-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/10-mobile-crm.png b/aos-shots/10-mobile-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-estimate-detail.png b/aos-shots/v10-estimate-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-estimates.png b/aos-shots/v10-estimates.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-support-summarized.png b/aos-shots/v10-support-summarized.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-support.png b/aos-shots/v10-support.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting-mrr.png b/aos-shots/v11-reporting-mrr.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting-utilization.png b/aos-shots/v11-reporting-utilization.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting.png b/aos-shots/v11-reporting.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step1.png b/aos-shots/v11-wizard-step1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step3-modules.png b/aos-shots/v11-wizard-step3-modules.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step4-mail.png b/aos-shots/v11-wizard-step4-mail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v12-wizard.png b/aos-shots/v12-wizard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-bank.png b/aos-shots/v13-accounting-bank.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-dunning.png b/aos-shots/v13-accounting-dunning.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-eur.png b/aos-shots/v13-accounting-eur.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-vat.png b/aos-shots/v13-accounting-vat.png old mode 100644 new mode 100755 diff --git a/aos-shots/v14-mail-real-test.png b/aos-shots/v14-mail-real-test.png old mode 100644 new mode 100755 diff --git a/aos-shots/v14-vat-real-xml.png b/aos-shots/v14-vat-real-xml.png old mode 100644 new mode 100755 diff --git a/aos-shots/v15-api-docs.png b/aos-shots/v15-api-docs.png old mode 100644 new mode 100755 diff --git a/aos-shots/v15-api-tokens.png b/aos-shots/v15-api-tokens.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approval-dialog.png b/aos-shots/v16-portal-approval-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approval-done.png b/aos-shots/v16-portal-approval-done.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approvals.png b/aos-shots/v16-portal-approvals.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-invoices.png b/aos-shots/v16-portal-invoices.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-login.png b/aos-shots/v16-portal-login.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-overview.png b/aos-shots/v16-portal-overview.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-de.png b/aos-shots/v17-de.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-debug1.png b/aos-shots/v17-debug1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-dropdown-open.png b/aos-shots/v17-dropdown-open.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-final.png b/aos-shots/v17-en-final.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-persisted.png b/aos-shots/v17-en-persisted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-real-final.png b/aos-shots/v17-en-real-final.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-real.png b/aos-shots/v17-en-real.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-verified.png b/aos-shots/v17-en-verified.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en.png b/aos-shots/v17-en.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-locale-dropdown.png b/aos-shots/v17-locale-dropdown.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-settings.png b/aos-shots/v17-settings.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-tr-persisted.png b/aos-shots/v17-tr-persisted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-tr.png b/aos-shots/v17-tr.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-kb-article.png b/aos-shots/v18-kb-article.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-kb.png b/aos-shots/v18-kb.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-notifications.png b/aos-shots/v18-notifications.png old mode 100644 new mode 100755 diff --git a/aos-shots/v19-calendar-next.png b/aos-shots/v19-calendar-next.png old mode 100644 new mode 100755 diff --git a/aos-shots/v19-calendar.png b/aos-shots/v19-calendar.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-CRM.png b/aos-shots/v2-CRM.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Dashboard.png b/aos-shots/v2-Dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Erweiterungen.png b/aos-shots/v2-Erweiterungen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Finanzen.png b/aos-shots/v2-Finanzen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Lizenzen.png b/aos-shots/v2-Lizenzen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Projekte.png b/aos-shots/v2-Projekte.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-ai-explain.png b/aos-shots/v2-ai-explain.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-leads-initial.png b/aos-shots/v2-leads-initial.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-dismissed.png b/aos-shots/v20-onboarding-dismissed.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-last.png b/aos-shots/v20-onboarding-last.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-step1.png b/aos-shots/v20-onboarding-step1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-step2.png b/aos-shots/v20-onboarding-step2.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-crm.png b/aos-shots/v21-dark-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-dashboard.png b/aos-shots/v21-dark-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-finance.png b/aos-shots/v21-dark-finance.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-portal.png b/aos-shots/v21-dark-portal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-calendar.png b/aos-shots/v22-mobile-calendar.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-dashboard.png b/aos-shots/v22-mobile-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-leads.png b/aos-shots/v22-mobile-leads.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-menu.png b/aos-shots/v22-mobile-menu.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-projects.png b/aos-shots/v22-mobile-projects.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-search.png b/aos-shots/v22-mobile-search.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-audit.png b/aos-shots/v3-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-company-drawer.png b/aos-shots/v3-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-invoice-dialog.png b/aos-shots/v3-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-projects-task.png b/aos-shots/v3-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-audit.png b/aos-shots/v4-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-company-drawer.png b/aos-shots/v4-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-create-deal.png b/aos-shots/v4-create-deal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-invoice-dialog.png b/aos-shots/v4-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-lead-approved.png b/aos-shots/v4-lead-approved.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-lead-converted.png b/aos-shots/v4-lead-converted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-projects-task.png b/aos-shots/v4-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-projects-time.png b/aos-shots/v4-projects-time.png old mode 100644 new mode 100755 diff --git a/aos-shots/v5-company-drawer.png b/aos-shots/v5-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-audit-detail.png b/aos-shots/v6-audit-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-audit.png b/aos-shots/v6-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-command-search.png b/aos-shots/v6-command-search.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-company-drawer.png b/aos-shots/v6-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal-done.png b/aos-shots/v6-create-deal-done.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal-filled.png b/aos-shots/v6-create-deal-filled.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal.png b/aos-shots/v6-create-deal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-invoice-dialog.png b/aos-shots/v6-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-lead-approved.png b/aos-shots/v6-lead-approved.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-lead-converted.png b/aos-shots/v6-lead-converted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-projects-task.png b/aos-shots/v6-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-projects-time.png b/aos-shots/v6-projects-time.png old mode 100644 new mode 100755 diff --git a/aos-shots/v7-palette-open.png b/aos-shots/v7-palette-open.png old mode 100644 new mode 100755 diff --git a/aos-shots/v7-search-results.png b/aos-shots/v7-search-results.png old mode 100644 new mode 100755 diff --git a/aos-shots/v8-search-bauer.png b/aos-shots/v8-search-bauer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-contract-detail.png b/aos-shots/v9-contract-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-contracts.png b/aos-shots/v9-contracts.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-expenses-report.png b/aos-shots/v9-expenses-report.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-expenses.png b/aos-shots/v9-expenses.png old mode 100644 new mode 100755 diff --git a/bun.lock b/bun.lock old mode 100644 new mode 100755 diff --git a/db/custom.db b/db/custom.db index 8639c571b7c01d7058c29a809534205db9944cdd..1bf26831ff12e2311c54a325f865a0730d67b937 100755 GIT binary patch delta 4026 zcmai14R9036~4XG>GXf{k1QXOuq;7r#}>9M%eF|UY>fD`F*X)~LV`I-CnH;b){nsu zVk;#j!$1QOnT3QT(3z6{P#o$eYHGkFQ<{*(B}_AErcIhOowSooN=-YHbkb?uy_2xz zm`qM{tG%~x-@W&}-EZHGUeS+^EPz&v)Hn|Q4&2#=yy?z z?V={H>?1{ntL_aqa_xT2r`%DAeuxYuebz_gEYtu%H zn{oBHEv;)@AB~00y(B#tN8L^6)1C1L?Khy+}CC=iQ; zL&zm0jmFS4Zz2#0oA$?dvGw-G<%yUh&^K7`^M$~f?^PB(FkYOTmJ!9upFqk;RH4X+ zmFe1AN_*#Ei^pF*vH~xpq+=Lerm`w~6iNAE*(vE5ns`z=lIo`xJMVGoikNZ?laeV7 zN(xEpaDek^z_2WecM5+t>|E5HZQr%j)md-D6+v*>c(^>ap=O(%jm8k%ks&JzkVWwg zaYCdcLa5+}++@Yoc%ZMh-p5)SoBV!#;Xkt^VeEudTk(-|TRB?7k+4)xQm#d84KGDme3&f8uH=5>D(24i`O2tDi*BRcv`R z6%sFYHaS=5bh&8N!Wz^`B$8aXu=_e-;He4Vmd&LCPEUaK%eLk0fioj9c1_ApE_OPc zb^$n$1rEdJEWyr;z@Km9mlr{M?K~T+Q`<$n4P8;$9QgS*NO*+z#&QX)%(oP|^mT7s zsqF+Gm`<0M#(Ya~>Jqp=-$vEtuiIFa8o+sPtmV9oJ4JNxG8o9W@vqCEeLgB}sU3i~ zvGf~J>7E2T@@xp ze1g6UAR(t=igKELWBAFlZ-KXpdAP*%6BvDyeh&fmEBY+`M5Z80q-e96sF1qb-In^g z`bM5o8VZOCkT0;|@QoSL1h37Ig)lQi77%p~@r$FxO**}GtWd91Zp5V6c})IOc|`J> z1d6A{8PR^xcaZ3OMt=pjDfzrzNPj3<1Vd9e%|9JDI68%+o5+k#iBb=*Al}DH@ODD6 zLza}?D_Jl7LTr&p$#=*?(Q~2}B1Rvkt5t8R`cxw2gGz(qS^QJHo&PBhC|i}S>2h^< z=CLmFBFOC6K~?}5>mdh3ZE+dt$?Ex_gXiV55lWhBzO+h4c70M8aWXYabW(4x?jI zV9hH+doizl8Dae?-6HJI1xAH6NF*`(E=9khLNX~iN+gNZ_$T-|{M>8|s6+e)t;~mU z+!lf@S8#oKnA_d!4tNK+M6Y$Oe?QVZ&QIGu0QYSs%Yh!=aFZJNi_PQ`+|mz$9@q0* zQC%iKR2rbPIvqGpC0I|8%N^gjKN-B<4m8lv39i9#2WW(^9S~_0xii}6c5QXT4>|!y z#gp+U7xtxFiDGfK3qb?W(BVh|l>=Q|ED-UnVuxYDI-oCb)YZt0=*phuuCNapPY}fg z@uVm2jRm6oDc+M{V+ld5@C1UvK)A0B0n4S2DH-_C%Q#I&W5_Oi`6N*(^9ADGNHU!0 z^1{@!#1cuEOLWbSQeHOf<$^)M9Zl|mOzV`#7;0-s}KkIJyx$1gkdyM%)`^V_Xgxnk{7jn}U2}RlPaPDttyh6GTNCNTfx`WY`pkfIt ztU&}HUoGXkoE>P5MuPzX3&pGeE#rL2CaxdhvQUCRDN<)At(uRxLWm~OD!dx*e4KE> z&MjnPQP$gRthYyx9w$m=Ldb$TywM11__)QbezA6QZTg0+bR=s?aMdfMs@J+elz7C! zQQF#Ca3~AS0)+*Fc-tmdtJ`ICx327P8BGhuO~%UXs)YS|d zG@+Kx58LBO#sgt49^Vl0alv_Db9G5Ipbz`Nu^{*#st~W-z)$ zb+>Z6V!J#<%}8I8{8siiL?Rzk`qU~hgK9Cv6Sh{YOk2)WOAY8KLmwC<$+KL#^r^uW zx0z00vddX+qlIctD?GfCib@z~o5pM_uX0rBv{thvTNX-?6&MZUOxkvGQa3^|PKU;9 zH`rZE4O)xYHK&xm^&XR6{N&UKa-@2`KXXc`LXI`>-P_!R=r}YukP=B5C*o>!Suh=j zF<4I3Img@!ifK10rj6O87}tb6Yx!@Pmbq^kRcI|6@+4z@tgw`M1S*9PQDuzk;H#+t zQi#D?W_MOGTC>4E=N?Q=0w(>+Q@_^j7ctK60`tali=&#+R+*RPDc!OlG=6;QOZF?<9 zIRjxIw`alP>P4SpJO{1w^`g(X1;aJMF|&GseEu|(abxG5c3`Dx*1D;R4Z{z5NHeI; zo?NAFvJ$Tj!R{&iZo~ZtD}$7lVZaZn1!Bw|fZ5iNIWdJh0hP^RTY8kxsA7>M+Kui= z)-Qo6)m+@m2HC9G7Yc+0DV387^q2X=?{HLwj6Nz7L-l%~AcYseuL|)n{%z+fkpCay CwxD|e delta 1277 zcmXw(ZEO@p7{_OJcXsb?cW>@`ce!k@*K(Y-l(Mwtde%c_`$8eMKwD_Bwb&P=Q3A10 zS``a&YaketrjSh?v2W-Hng|idMOK8^`URCzYa*!@5o{A7QB&J5YOH{>*Sg7Uvd{1T zyv~!2$J67ZX(s~i9soc)J^w#Xom>?O^?=oH>_gPV;~5KV#|!vRJdc0J_wkI~?ijlm zufvX4cD25KVE^9T9j%EOyu6w9Ag>|p%Swb?_qc?&bI!qf-|X{to_fvNQuLQ?ZmrZ8j)byf*#UokSS3H#3Aa4232rUs z3rB;|vdo}g4VNKg%->eXY`rk<)w6iF7WL3NBgG)YA5wAfPW($nuS3i+@>_{`JExwUF{xH6>1auk1dIFr(OmC_SCgd3jq zY|hMIb>KyO(igmNh5Pb(9Iq7D1te=8K&&?%2daT9RQa)56UAll8VF* zdOyt~)}Q4itx$U=5U?ZNhm7j;iZS_qjZx57?n_R1qh5j}Ek>_Hfk{OhRb1=aWTf?1 zEGjnWg`A}bcrq4abW_Fo{t{!Med&7wxo_dW6H6UW z1Ki;3l^5iQ^tL!nJ02G${V_g8%PLcpfCk9a80`11K-5qj^!POZAVdg+>6R?JNw?(8 z4fv+FjH9?V=nH7tA`VeJ`aQ+92AFkl z3cdfAZi`{fgaLAKclH^3>H^zQ_Hs0sF8}Qm>S($^Q5}HWXl>xVPkd+Rc)I%+`qCA(w+G5-ba4#Olm+{zf*BmlkbNpQHf2NdVm;e9( diff --git a/package.json b/package.json index 068be6a..13da23a 100755 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.2.1", "private": true, "scripts": { - "dev": "next dev -p 3000 2>&1 | tee dev.log", + "dev": "next dev -p 3000", + "dev:log": "next dev -p 3000 2>&1 | tee dev.log", "build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/", "start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log", "lint": "eslint .", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c67ed1..4cf788f 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,8 +71,28 @@ model Company { city String? zip String? street String? - source String? // lead-finder|import|manual|referral + source String? // lead-finder|import|manual|referral|agency|self-signup tags String? // comma separated + + // --- B2B Agency / Reseller (PRD §6.5) --- + // A company may be a regular client OR an agency (reseller). Agencies + // onboard their own clients and license them; they cannot create invoices + // (they must use their own accounting system, e.g. lexoffice/sevdesk via REST API). + isAgency Boolean @default(false) + // Agency approval: pending (just signed up) → approved (admin allowed) → rejected. + agencyStatus String? // null when not an agency; "pending"|"approved"|"rejected" + agencyAppliedAt DateTime? + agencyApprovedAt DateTime? + agencyRejectedAt DateTime? + agencyRejectReason String? + // Admin-set discount percentage applied to all licenses this agency buys + // from us. Range 0-20 (admin decision only). Default 0. + agencyDiscountPct Int @default(0) + // Self-serve clients belong to an agency. NULL = direct client of ours. + parentAgencyId String? // FK to Company(id) — the agency that owns this client + parentAgency Company? @relation("AgencyClients", fields: [parentAgencyId], references: [id]) + agencyClients Company[] @relation("AgencyClients") + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt persons Person[] @@ -83,6 +103,8 @@ model Company { projects Project[] contracts Contract[] expenses Expense[] + tickets Ticket[] + agencyApplication AgencyApplication? } model Person { @@ -216,12 +238,59 @@ model License { key String @unique companyId String company Company @relation(fields: [companyId], references: [id]) - product String // e.g. aos-cms, wp-plugin-xyz + product String // e.g. aos-cms, restov1, wp-plugin-xyz plan String // starter|pro|enterprise - status String @default("active") // active|suspended|revoked|expired + status String @default("active") // active|suspended|revoked|expired|cancelled + // environment: demo licenses are feature-limited / watermarked / time-boxed + // and can be promoted to production via the license server. + environment String @default("production") // demo|production + // Modular licensing: a license may be the "core" license, or a module add-on. + // Module licenses reference their parent core license via parentId. + parentId String? // null = core license; non-null = module add-on + parent License? @relation("LicenseModules", fields: [parentId], references: [id]) + modules License[] @relation("LicenseModules") + // The module key this license grants (only when parentId is set). + // e.g. "online-order", "payment", "reservation", "delivery", "table-service" + module String? // null for core licenses maxActivations Int @default(1) validUntil DateTime? token String? // Ed25519-signed token (mocked) + // Suspend/bloke reason + timestamp — set when status -> suspended. + suspendReason String? + suspendedAt DateTime? + + // --- B2B Subscription & Billing (German law) --- + // Kündigung frist: 3 months (90 days) notice period before the next renewal. + // A client can cancel, but the cancellation only takes effect at the end of + // the notice period. Set on issuance, defaults to 90 days. + noticePeriodDays Int @default(90) + // Trial: first 14 days are a free trial. Subscription can be cancelled within + // these 14 days at any time with immediate effect (no charge). Day 15 the + // paid subscription starts. + trialEndsAt DateTime? // null = no trial (e.g. agency-issued) + subscriptionStartsAt DateTime? // when the paid subscription begins (trial + 1 day) + // Billing cycle: monthly or yearly. Yearly gets a discount on the total. + billingCycle String @default("monthly") // monthly|yearly + // The price in cents per billing cycle BEFORE any discount. + // For monthly: this is the monthly price (e.g. 5000 = 50 €). + // For yearly: this is the yearly total (e.g. 60000 = 600 € before yearly discount). + priceCents Int @default(0) + // Discount percentage applied to this license (0-100). + // For agencies: copied from Company.agencyDiscountPct at issue time. + // For yearly billing: a yearly discount (e.g. 10%) is applied on top. + discountPct Int @default(0) + // The effective amount actually charged (priceCents - all discounts). + // Computed at issue time and stored for audit/GoBD. + netPriceCents Int @default(0) + // Cancellation tracking (Kündigung). + cancelledAt DateTime? // when the client cancelled + cancellationEffectiveAt DateTime? // when the cancellation takes effect (notice period end) + cancellationReason String? + // Stripe integration (optional — populated when paid via Stripe). + stripeCustomerId String? + stripeSubscriptionId String? + stripePriceId String? + createdAt DateTime @default(now()) activations LicenseActivation[] } @@ -367,8 +436,17 @@ model Ticket { priority String @default("normal") // low|normal|high|urgent categoryId String // bug|anfrage|aenderung|konto|rechnung|hosting companyId String + company Company @relation(fields: [companyId], references: [id]) projectId String? licenseId String? + // --- Ticket routing (agency model) --- + // If the client belongs to an agency, the ticket is routed to that agency + // (agencyCompanyId). The agency handles it; only if the agency escalates + // does the ticket reach us (routedToAgency = false, escalatedToAdmin = true). + agencyCompanyId String? // the reseller that owns this client (if any) + routedToAgency Boolean @default(false) // ticket sent to agency inbox + escalatedToAdmin Boolean @default(false) // agency escalated to us + escalatedAt DateTime? // SLA slaResponseHours Int @default(8) // first response target slaResolveHours Int @default(48) // resolution target @@ -387,12 +465,40 @@ model TicketMessage { id String @id @default(cuid()) ticketId String ticket Ticket @relation(fields: [ticketId], references: [id]) - fromType String // customer|staff|system + fromType String // customer|staff|system|agency fromName String body String createdAt DateTime @default(now()) } +// --------------------------------------------------------------------------- +// Agency Applications (PRD §6.5 — reseller onboarding) +// --------------------------------------------------------------------------- +// When a company signs up, they may choose to become an agency (reseller). +// The application is stored here and awaits admin approval. On approval, +// the Company.isAgency flag is set, Company.agencyStatus = "approved", and +// an email is sent to the applicant with their discount percentage. +model AgencyApplication { + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id]) + status String @default("pending") // pending|approved|rejected + // Discount the admin grants this agency (0-20%). Applied to all licenses + // the agency buys from us. Admin-only decision. + discountPct Int @default(0) // 0-20 + // Business justification fields (filled by the applicant) + businessModel String? // e.g. "Webdesign agency", "IT consultant" + expectedClientVolume String? // e.g. "10-20 clients" + referredBy String? // how did they hear about us + // Review + reviewedBy String? + reviewedAt DateTime? + rejectReason String? + appliedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // --------------------------------------------------------------------------- // Estimates / Angebote (PRD §6.5 sales-docs — quotes that convert to invoice) // --------------------------------------------------------------------------- diff --git a/src/app/api/aos/agency/[id]/approve/route.ts b/src/app/api/aos/agency/[id]/approve/route.ts new file mode 100644 index 0000000..422b346 --- /dev/null +++ b/src/app/api/aos/agency/[id]/approve/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/agency/[id]/reject/route.ts b/src/app/api/aos/agency/[id]/reject/route.ts new file mode 100644 index 0000000..7b04dd8 --- /dev/null +++ b/src/app/api/aos/agency/[id]/reject/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/agency/apply/route.ts b/src/app/api/aos/agency/apply/route.ts new file mode 100644 index 0000000..fdebdac --- /dev/null +++ b/src/app/api/aos/agency/apply/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/agency/route.ts b/src/app/api/aos/agency/route.ts new file mode 100644 index 0000000..244dd9a --- /dev/null +++ b/src/app/api/aos/agency/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/clients/route.ts b/src/app/api/aos/clients/route.ts new file mode 100644 index 0000000..36b2a0c --- /dev/null +++ b/src/app/api/aos/clients/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/crm/mail-sync/route.ts b/src/app/api/aos/crm/mail-sync/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/aos/leads/discover/route.ts b/src/app/api/aos/leads/discover/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/aos/licenses/[id]/cancel/route.ts b/src/app/api/aos/licenses/[id]/cancel/route.ts new file mode 100644 index 0000000..34f5652 --- /dev/null +++ b/src/app/api/aos/licenses/[id]/cancel/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/[id]/promote/route.ts b/src/app/api/aos/licenses/[id]/promote/route.ts new file mode 100644 index 0000000..2710522 --- /dev/null +++ b/src/app/api/aos/licenses/[id]/promote/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/[id]/suspend/route.ts b/src/app/api/aos/licenses/[id]/suspend/route.ts new file mode 100644 index 0000000..0d7feca --- /dev/null +++ b/src/app/api/aos/licenses/[id]/suspend/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/check/route.ts b/src/app/api/aos/licenses/check/route.ts new file mode 100644 index 0000000..0ec0ab6 --- /dev/null +++ b/src/app/api/aos/licenses/check/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/route.ts b/src/app/api/aos/licenses/route.ts index 780b337..a1806b2 100755 --- a/src/app/api/aos/licenses/route.ts +++ b/src/app/api/aos/licenses/route.ts @@ -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 = { starter: 'ST', pro: 'PRO', enterprise: 'ENT' } + +// Default monthly prices per plan (cents). Used when no explicit priceCents given. +const PLAN_PRICES: Record = { 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 { + 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() + 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 }) + } +} diff --git a/src/app/api/aos/licenses/validate/route.ts b/src/app/api/aos/licenses/validate/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/aos/tickets/[id]/escalate/route.ts b/src/app/api/aos/tickets/[id]/escalate/route.ts new file mode 100644 index 0000000..d1650b9 --- /dev/null +++ b/src/app/api/aos/tickets/[id]/escalate/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/tickets/route.ts b/src/app/api/aos/tickets/route.ts index e8e9b7a..fa26fe9 100755 --- a/src/app/api/aos/tickets/route.ts +++ b/src/app/api/aos/tickets/route.ts @@ -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 }) + } +} diff --git a/src/components/FuseButton.tsx b/src/components/FuseButton.tsx new file mode 100644 index 0000000..0734026 --- /dev/null +++ b/src/components/FuseButton.tsx @@ -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, '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 + /** Optional undo handler. When provided, the success toast shows a "Rückgängig" action. */ + onUndo?: () => Promise | 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('idle') + const [progress, setProgress] = React.useState(0) + const holdRef = React.useRef | null>(null) + const startRef = React.useRef(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) => { + 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: ( + <> + + 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 ( + + ) +} diff --git a/src/components/SlideCommit.tsx b/src/components/SlideCommit.tsx new file mode 100644 index 0000000..951f17b --- /dev/null +++ b/src/components/SlideCommit.tsx @@ -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 + /** 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(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) => { + 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) => { + 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 ( +
+ {/* track label */} + 0.1 && 'opacity-0' + )} + > + {label} + + + {/* progress fill (foreground-tinted) */} +
+ + {/* handle */} +
+ {loading ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index ec5d2f3..fdc9edf 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -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' && } {view === 'support' && } {view === 'licenses' && } + {view === 'agency' && } {view === 'contracts' && } {view === 'expenses' && } {view === 'finance' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 1f55256..22869d9 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -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' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 8981caf..8eca56b 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -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' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 0bafc68..310cefe 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -28,6 +28,7 @@ const TITLES: Record = { 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' }, diff --git a/src/components/aos/views/agency.tsx b/src/components/aos/views/agency.tsx new file mode 100644 index 0000000..ac53d9d --- /dev/null +++ b/src/components/aos/views/agency.tsx @@ -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('/api/aos/agency') + + if (loading || !data) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ ) + } + + 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 ( +
+ + + {/* Stats */} +
+ + + + +
+ + + + +
+ ) +} + +/* ----------------------------- stats card ----------------------------- */ + +function StatCard({ + label, + value, + icon: Icon, + hint, +}: { + label: string + value: number | string + icon: typeof Inbox + hint?: string +}) { + return ( + +
+

{label}

+ +
+

{value}

+ {hint &&

{hint}

} +
+ ) +} + +/* ----------------------------- pending applications ----------------------------- */ + +function PendingApplicationsSection({ + applications, + onChanged, +}: { + applications: AgencyApplication[] + onChanged: () => void +}) { + const [approveTarget, setApproveTarget] = useState(null) + const [rejectTarget, setRejectTarget] = useState(null) + + return ( +
+
+

+ Bewerbende Bayis + + {applications.length} offen + +

+
+ + {applications.length === 0 ? ( +
+
+ +
+
+

Keine bewerbenden Anfragen

+

+ Neue Bayi-Bewerbungen erscheinen hier. +

+
+
+ ) : ( +
+ {applications.map((app) => ( + + + {/* header */} +
+
+

{app.company.name}

+
+ {app.company.email && ( + + + {app.company.email} + + )} + {app.company.website && ( + + + {app.company.website} + + )} + {app.company.city && ( + + + {app.company.city} + + )} +
+
+ + + {formatRelative(app.appliedAt)} + +
+ + {/* application details */} + {(app.businessModel || app.expectedClientVolume || app.referredBy) && ( +
+ {app.businessModel && ( + + )} + {app.expectedClientVolume && ( + + )} + {app.referredBy && ( + + )} +
+ )} + + {/* actions */} +
+ + +
+
+
+ ))} +
+ )} + + setApproveTarget(null)} + onChanged={onChanged} + /> + setRejectTarget(null)} + onChanged={onChanged} + /> +
+ ) +} + +function Meta({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +/* ----------------------------- 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 ( + + + {application && ( + <> + + + + Bayi-Bewerbung genehmigen + + + {application.company.name} wird + als Bayi freigeschaltet. Der Rabatt wird auf alle vom Bayi angelegten Kundenlizenzierungen + angewendet. + + + +
+
+
+ + 0 – 20 +
+ { + 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" + /> +
+ + {/* live preview */} +
+

+ Preisvorschau +

+

+ {BASE_PRICE_EUR} €/Monat + Basis + → + mit {discount}% Rabatt + = + + {net} € + +

+
+
+ + + + + + + )} +
+
+ ) +} + +/* ----------------------------- 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 ( + + + {application && ( + <> + + + + Bayi-Bewerbung ablehnen + + + {application.company.name} + {' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert. + + + +
+ +