Compare commits
40 Commits
8d8f4e94ee
...
feat/membe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c5aaabae5 | ||
|
|
124c6a632a | ||
|
|
d4acc3ba22 | ||
|
|
28188bb3a6 | ||
|
|
f10a34c505 | ||
|
|
f43770999f | ||
|
|
0932c57fa1 | ||
|
|
a1aa1bee86 | ||
|
|
a3be926f6f | ||
|
|
4f40abdce4 | ||
|
|
d87fbb050f | ||
|
|
a6c9537195 | ||
|
|
da862f2194 | ||
|
|
0bd5d0cf42 | ||
|
|
c6d564836f | ||
|
|
b26e5aaafa | ||
|
|
a1719671df | ||
|
|
a5bbf42901 | ||
|
|
bbb33aa63d | ||
|
|
db4e19c3af | ||
|
|
080ec1cb47 | ||
|
|
c6b2824da8 | ||
|
|
7b078f298b | ||
|
|
f82a366a52 | ||
|
|
49fd6b65b9 | ||
|
|
5e1976f07b | ||
|
|
4aa11cd408 | ||
|
|
a5baaae12f | ||
|
|
c98cada7f6 | ||
|
|
9484ba91f8 | ||
|
|
2a9d543ee4 | ||
|
|
5294cfab61 | ||
|
|
98afe6aa5f | ||
|
|
da8a43a3b0 | ||
|
|
abac22feb1 | ||
|
|
3bcc5c70a3 | ||
|
|
fd8c2cc32a | ||
|
|
d3db316a68 | ||
|
|
2f80d5cc4a | ||
|
|
59546ad6d2 |
1
.bg-shell/manifest.json
Normal file
1
.bg-shell/manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -3,8 +3,9 @@ node_modules
|
|||||||
.turbo
|
.turbo
|
||||||
**/.turbo
|
**/.turbo
|
||||||
.git
|
.git
|
||||||
*.md
|
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
.DS_Store
|
.DS_Store
|
||||||
apps/e2e
|
apps/e2e
|
||||||
apps/dev-tool
|
apps/dev-tool
|
||||||
@@ -16,3 +17,6 @@ apps/dev-tool
|
|||||||
.github
|
.github
|
||||||
docs
|
docs
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
|
**/*.md
|
||||||
|
!**/AGENTS.md
|
||||||
|
!**/CLAUDE.md
|
||||||
|
|||||||
19
.env.local.example
Normal file
19
.env.local.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# =====================================================
|
||||||
|
# MyEasyCMS v2 — Local Development Environment
|
||||||
|
# Copy to .env and run: docker compose -f docker-compose.local.yml up -d
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
|
||||||
|
# --- Supabase Auth ---
|
||||||
|
JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
|
||||||
|
|
||||||
|
# --- Supabase Keys (demo keys — safe for local dev only) ---
|
||||||
|
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
|
|
||||||
|
# --- Stripe (test keys) ---
|
||||||
|
# Get your own test keys from https://dashboard.stripe.com/test/apikeys
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
|
||||||
|
STRIPE_SECRET_KEY=sk_test_YOUR_KEY
|
||||||
@@ -44,6 +44,12 @@ ADDITIONAL_REDIRECT_URLS=
|
|||||||
# --- Webhooks ---
|
# --- Webhooks ---
|
||||||
DB_WEBHOOK_SECRET=your-webhook-secret
|
DB_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
|
# --- Monitoring (Sentry) ---
|
||||||
|
NEXT_PUBLIC_MONITORING_PROVIDER=sentry
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123456.ingest.sentry.io/123456
|
||||||
|
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
|
||||||
|
# SENTRY_AUTH_TOKEN=your-auth-token-for-source-maps
|
||||||
|
|
||||||
# --- Feature Flags ---
|
# --- Feature Flags ---
|
||||||
# All default to true, set to false to disable
|
# All default to true, set to false to disable
|
||||||
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true
|
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ After implementation, always run:
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -1,40 +1,49 @@
|
|||||||
FROM node:22-alpine AS base
|
# node:22-slim (Debian/glibc) is ~2x faster for Next.js builds vs Alpine/musl
|
||||||
|
FROM node:22-slim AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# --- Install + Build in one stage ---
|
# --- Install + Build ---
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# CACHE_BUST: change this value to force a full rebuild (busts Docker layer cache)
|
|
||||||
ARG CACHE_BUST=11
|
|
||||||
RUN echo "Cache bust: ${CACHE_BUST}"
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --no-frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --no-frozen-lockfile --prefer-offline
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# NEXT_PUBLIC_* vars are baked into the Next.js build at compile time.
|
ARG NEXT_PUBLIC_CI=false
|
||||||
# Pass them as build args so the same Dockerfile works for any environment.
|
|
||||||
ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de
|
ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de
|
||||||
ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
||||||
ARG NEXT_PUBLIC_SUPABASE_PUBLIC_KEY
|
ARG NEXT_PUBLIC_SUPABASE_PUBLIC_KEY
|
||||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=de
|
ARG NEXT_PUBLIC_DEFAULT_LOCALE=de
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_FISCHEREI=true
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=true
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
|
||||||
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
||||||
|
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
|
||||||
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=${NEXT_PUBLIC_SUPABASE_PUBLIC_KEY}
|
ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=${NEXT_PUBLIC_SUPABASE_PUBLIC_KEY}
|
||||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=${NEXT_PUBLIC_DEFAULT_LOCALE}
|
ENV NEXT_PUBLIC_DEFAULT_LOCALE=${NEXT_PUBLIC_DEFAULT_LOCALE}
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_FISCHEREI=${NEXT_PUBLIC_ENABLE_FISCHEREI}
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=${NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS}
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
|
||||||
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
|
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
|
||||||
RUN pnpm --filter web build
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
# --- Run ---
|
# --- Run (slim for smaller image than full Debian) ---
|
||||||
FROM base AS runner
|
FROM node:22-slim AS runner
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=builder /app/ ./
|
COPY --from=builder /app/ ./
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --create-home nextjs
|
||||||
|
|
||||||
# Ensure Next.js cache directories are writable by the nextjs user
|
|
||||||
RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache
|
RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache
|
||||||
|
RUN mkdir -p /home/nextjs/.cache/node/corepack && chown -R nextjs:nodejs /home/nextjs/.cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* E2E Test: Course Enrollment
|
* E2E Test: Course Enrollment
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Course Management', () => {
|
test.describe('Course Management', () => {
|
||||||
test('create course, enroll participant, check capacity, waitlist', async ({ page }) => {
|
test('create course, enroll participant, check capacity, waitlist', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
// Create course with capacity 2
|
// Create course with capacity 2
|
||||||
// Enroll participant 1 → status: enrolled
|
// Enroll participant 1 → status: enrolled
|
||||||
// Enroll participant 2 → status: enrolled
|
// Enroll participant 2 → status: enrolled
|
||||||
// Enroll participant 3 → status: waitlisted (capacity full)
|
// Enroll participant 3 → status: waitlisted (capacity full)
|
||||||
});
|
});
|
||||||
|
|
||||||
test('course calendar view shows sessions', async ({ page }) => {
|
test('course calendar view shows sessions', async ({ page: _page }) => {
|
||||||
// Create course with sessions
|
// Create course with sessions
|
||||||
// Navigate to calendar
|
// Navigate to calendar
|
||||||
// Verify sessions visible
|
// Verify sessions visible
|
||||||
});
|
});
|
||||||
|
|
||||||
test('attendance tracking', async ({ page }) => {
|
test('attendance tracking', async ({ page: _page }) => {
|
||||||
// Create course + session + participants
|
// Create course + session + participants
|
||||||
// Mark attendance
|
// Mark attendance
|
||||||
// Verify attendance persists
|
// Verify attendance persists
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Member Management', () => {
|
test.describe('Member Management', () => {
|
||||||
test('create member, edit, search, filter by status', async ({ page }) => {
|
test('create member, edit, search, filter by status', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
await page.fill('input[name="email"]', 'test@example.com');
|
await page.fill('input[name="email"]', 'test@example.com');
|
||||||
await page.fill('input[name="password"]', 'testpassword123');
|
await page.fill('input[name="password"]', 'testpassword123');
|
||||||
@@ -15,13 +17,15 @@ test.describe('Member Management', () => {
|
|||||||
await expect(page.locator('h1')).toContainText('Mitglieder');
|
await expect(page.locator('h1')).toContainText('Mitglieder');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('application workflow: submit → review → approve → member created', async ({ page }) => {
|
test('application workflow: submit → review → approve → member created', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
// Submit application
|
// Submit application
|
||||||
// Review application
|
// Review application
|
||||||
// Approve → verify member auto-created
|
// Approve → verify member auto-created
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SEPA mandate management', async ({ page }) => {
|
test('SEPA mandate management', async ({ page: _page }) => {
|
||||||
// Create member with IBAN
|
// Create member with IBAN
|
||||||
// Verify IBAN validation
|
// Verify IBAN validation
|
||||||
// Create SEPA batch from dues
|
// Create SEPA batch from dues
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Module Builder', () => {
|
test.describe('Module Builder', () => {
|
||||||
test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => {
|
test('create module, add fields, insert record, query, update, soft-delete', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
// Login
|
// Login
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
await page.fill('input[name="email"]', 'test@example.com');
|
await page.fill('input[name="email"]', 'test@example.com');
|
||||||
@@ -22,7 +24,7 @@ test.describe('Module Builder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Cross-tenant isolation', () => {
|
test.describe('Cross-tenant isolation', () => {
|
||||||
test('tenant A cannot see tenant B data', async ({ page }) => {
|
test('tenant A cannot see tenant B data', async ({ page: _page }) => {
|
||||||
// Login as tenant A user
|
// Login as tenant A user
|
||||||
// Verify can see own modules
|
// Verify can see own modules
|
||||||
// Verify cannot access tenant B module URL
|
// Verify cannot access tenant B module URL
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* E2E Test: Newsletter
|
* E2E Test: Newsletter
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Newsletter', () => {
|
test.describe('Newsletter', () => {
|
||||||
test('create campaign, select recipients from members, preview, send', async ({ page }) => {
|
test('create campaign, select recipients from members, preview, send', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
// Create newsletter
|
// Create newsletter
|
||||||
// Add recipients from member filter (status=active, hasEmail=true)
|
// Add recipients from member filter (status=active, hasEmail=true)
|
||||||
// Preview with variable substitution
|
// Preview with variable substitution
|
||||||
@@ -12,7 +14,7 @@ test.describe('Newsletter', () => {
|
|||||||
// Verify sent_count
|
// Verify sent_count
|
||||||
});
|
});
|
||||||
|
|
||||||
test('template variable substitution works', async ({ page }) => {
|
test('template variable substitution works', async ({ page: _page }) => {
|
||||||
// Create template with {{first_name}} {{member_number}}
|
// Create template with {{first_name}} {{member_number}}
|
||||||
// Create newsletter from template
|
// Create newsletter from template
|
||||||
// Preview — verify variables replaced
|
// Preview — verify variables replaced
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* E2E Test: SEPA Batch Processing
|
* E2E Test: SEPA Batch Processing
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('SEPA / Finance', () => {
|
test.describe('SEPA / Finance', () => {
|
||||||
test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => {
|
test('create SEPA direct debit batch, add items, generate XML', async ({
|
||||||
|
page: _page,
|
||||||
|
}) => {
|
||||||
// Create batch
|
// Create batch
|
||||||
// Add items with valid IBANs
|
// Add items with valid IBANs
|
||||||
// Generate XML
|
// Generate XML
|
||||||
@@ -12,12 +14,12 @@ test.describe('SEPA / Finance', () => {
|
|||||||
// Verify amounts sum correctly
|
// Verify amounts sum correctly
|
||||||
});
|
});
|
||||||
|
|
||||||
test('IBAN validation rejects invalid IBANs', async ({ page }) => {
|
test('IBAN validation rejects invalid IBANs', async ({ page: _page }) => {
|
||||||
// Try to add item with invalid IBAN
|
// Try to add item with invalid IBAN
|
||||||
// Verify rejection
|
// Verify rejection
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invoice creation with line items', async ({ page }) => {
|
test('invoice creation with line items', async ({ page: _page }) => {
|
||||||
// Create invoice
|
// Create invoice
|
||||||
// Add 3 line items
|
// Add 3 line items
|
||||||
// Verify subtotal, tax, total calculations
|
// Verify subtotal, tax, total calculations
|
||||||
|
|||||||
1
apps/web/.bg-shell/manifest.json
Normal file
1
apps/web/.bg-shell/manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -21,7 +21,9 @@ EMAIL_PASSWORD=password
|
|||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
# STRIPE
|
# STRIPE
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51SMbesKttnWb7SsFOR7cJ1jshdEaZiHmCflWndgLtL3gx1Cu8N4p5qxSJY8PHmpEJL8gf4VrqqX2Fr7pxJtQILUS00yYQ7Tx8V
|
||||||
|
|
||||||
# MAILER
|
# MAILER
|
||||||
MAILER_PROVIDER=nodemailer
|
MAILER_PROVIDER=nodemailer
|
||||||
|
# STRIPE SECRET KEY
|
||||||
|
STRIPE_SECRET_KEY=sk_test_51SMbesKttnWb7SsFTjCsPZMlxVe3WjrsDnpLDAQehVdzSoDaWMFmc3hiZOTp2IAKB1cleMPIOW9GmEJHEkhazJsq00FEfTr6BI
|
||||||
|
|||||||
@@ -9,4 +9,4 @@
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
|
||||||
# STRIPE
|
# STRIPE
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51SMbesKttnWb7SsFOR7cJ1jshdEaZiHmCflWndgLtL3gx1Cu8N4p5qxSJY8PHmpEJL8gf4VrqqX2Fr7pxJtQILUS00yYQ7Tx8V
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function AnimateOnScroll(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const reveals = el.querySelectorAll('.reveal');
|
||||||
|
reveals.forEach((r) => r.setAttribute('data-visible', 'true'));
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.15 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={props.className}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BedDouble,
|
||||||
|
CalendarDays,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LayoutDashboard,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function MiniStat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background rounded-lg border p-3 text-center">
|
||||||
|
<div className="text-foreground text-lg font-bold">{value}</div>
|
||||||
|
<div className="text-muted-foreground text-[10px]">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniRow({
|
||||||
|
cells,
|
||||||
|
highlighted,
|
||||||
|
}: {
|
||||||
|
cells: string[];
|
||||||
|
highlighted?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-2 border-b px-3 py-2 text-xs',
|
||||||
|
highlighted && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={i === 0 ? 'font-medium' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slide data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Slide {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IC = 'h-4 w-4';
|
||||||
|
|
||||||
|
const SLIDES: Slide[] = [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: <LayoutDashboard className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<MiniStat label="Mitglieder" value="1.247" />
|
||||||
|
<MiniStat label="Aktive Kurse" value="18" />
|
||||||
|
<MiniStat label="Offene Rechnungen" value="3.420 €" />
|
||||||
|
<MiniStat label="Newsletter" value="12" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-background space-y-2 rounded-lg border p-3">
|
||||||
|
<div className="text-xs font-semibold">Letzte Aktivität</div>
|
||||||
|
{[
|
||||||
|
'Max Müller — Beitritt',
|
||||||
|
'SEPA-Einzug #42 — erstellt',
|
||||||
|
'Schwimmkurs — 3 neue Teilnehmer',
|
||||||
|
].map((t) => (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
className="text-muted-foreground flex items-center gap-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<div className="bg-primary/20 h-1.5 w-1.5 rounded-full" />
|
||||||
|
{t}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-background space-y-2 rounded-lg border p-3">
|
||||||
|
<div className="text-xs font-semibold">Schnellaktionen</div>
|
||||||
|
{['Neues Mitglied', 'Neuer Kurs', 'Newsletter erstellen'].map(
|
||||||
|
(t) => (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
className="bg-muted flex items-center justify-between rounded px-2 py-1 text-[10px]"
|
||||||
|
>
|
||||||
|
{t} <span className="text-muted-foreground">→</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'members',
|
||||||
|
label: 'Mitglieder',
|
||||||
|
icon: <Users className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-[10px]">
|
||||||
|
🔍 Mitglied suchen…
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline" className="text-[9px]">
|
||||||
|
CSV
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[9px]">
|
||||||
|
Excel
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="default" className="text-[9px]">
|
||||||
|
+ Neues Mitglied
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-5 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Nr</span>
|
||||||
|
<span>Name</span>
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Eintritt</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'M-001',
|
||||||
|
'Müller, Hans',
|
||||||
|
'h.mueller@web.de',
|
||||||
|
'Aktiv',
|
||||||
|
'15.01.2024',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'M-002',
|
||||||
|
'Schmidt, Anna',
|
||||||
|
'a.schmidt@gmx.de',
|
||||||
|
'Aktiv',
|
||||||
|
'01.03.2024',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'M-003',
|
||||||
|
'Weber, Thomas',
|
||||||
|
'weber@t-online.de',
|
||||||
|
'Passiv',
|
||||||
|
'22.06.2023',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'M-004',
|
||||||
|
'Fischer, Maria',
|
||||||
|
'm.fischer@mail.de',
|
||||||
|
'Aktiv',
|
||||||
|
'08.09.2024',
|
||||||
|
],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} highlighted={i === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-center text-[10px]">
|
||||||
|
← Zurück Seite 1 von 52 Weiter →
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'courses',
|
||||||
|
label: 'Kurse',
|
||||||
|
icon: <GraduationCap className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<MiniStat label="Gesamt" value="24" />
|
||||||
|
<MiniStat label="Aktiv" value="18" />
|
||||||
|
<MiniStat label="Teilnehmer" value="342" />
|
||||||
|
<MiniStat label="Auslastung" value="78%" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-5 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Kurs-Nr</span>
|
||||||
|
<span>Name</span>
|
||||||
|
<span>Beginn</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Gebühr</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['SK-001', 'Schwimmkurs Anfänger', '01.05.2026', 'Aktiv', '50 €'],
|
||||||
|
['YG-003', 'Yoga für Senioren', '15.03.2026', 'Geplant', '35 €'],
|
||||||
|
['TN-012', 'Tenniskurs Jugend', '01.06.2026', 'Aktiv', '80 €'],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finance',
|
||||||
|
label: 'Finanzen',
|
||||||
|
icon: <Wallet className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="SEPA-Einzüge" value="8" />
|
||||||
|
<MiniStat label="Rechnungen" value="147" />
|
||||||
|
<MiniStat label="Offene Forderungen" value="3.420 €" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Rechnungs-Nr</span>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
<span>Betrag</span>
|
||||||
|
<span>Status</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['RE-2026-001', 'Hans Müller', '120,00 €', 'Bezahlt'],
|
||||||
|
['RE-2026-002', 'Anna Schmidt', '85,00 €', 'Offen'],
|
||||||
|
['RE-2026-003', 'Thomas Weber', '120,00 €', 'Überfällig'],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
label: 'Veranstaltungen',
|
||||||
|
icon: <CalendarDays className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Veranstaltungen" value="12" />
|
||||||
|
<MiniStat label="Anmeldungen" value="284" />
|
||||||
|
<MiniStat label="Kapazität" value="500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
name: 'Sommerfest 2026',
|
||||||
|
date: '21.06.2026',
|
||||||
|
spots: '84/120',
|
||||||
|
badge: 'Offen' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jahreshauptversammlung',
|
||||||
|
date: '15.03.2026',
|
||||||
|
spots: '200/200',
|
||||||
|
badge: 'Ausgebucht' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Weihnachtsfeier',
|
||||||
|
date: '20.12.2026',
|
||||||
|
spots: '0/80',
|
||||||
|
badge: 'Geplant' as const,
|
||||||
|
},
|
||||||
|
].map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.name}
|
||||||
|
className="bg-background flex items-center justify-between rounded-lg border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium">{e.name}</div>
|
||||||
|
<div className="text-muted-foreground text-[10px]">
|
||||||
|
{e.date} · {e.spots} Plätze
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
e.badge === 'Ausgebucht'
|
||||||
|
? 'destructive'
|
||||||
|
: e.badge === 'Offen'
|
||||||
|
? 'default'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
className="text-[9px]"
|
||||||
|
>
|
||||||
|
{e.badge}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newsletter',
|
||||||
|
label: 'Newsletter',
|
||||||
|
icon: <Mail className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Kampagnen" value="12" />
|
||||||
|
<MiniStat label="Gesendet" value="8" />
|
||||||
|
<MiniStat label="Empfänger" value="1.180" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Betreff</span>
|
||||||
|
<span>Empfänger</span>
|
||||||
|
<span>Datum</span>
|
||||||
|
<span>Status</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['Frühjahrsnewsletter', '1.180', '01.03.2026', 'Gesendet'],
|
||||||
|
['Einladung Sommerfest', '1.180', '15.05.2026', 'Entwurf'],
|
||||||
|
['Beitragsinfo 2026', '1.180', '15.01.2026', 'Gesendet'],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'website',
|
||||||
|
label: 'Website',
|
||||||
|
icon: <Globe className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Seiten" value="7" />
|
||||||
|
<MiniStat label="Veröffentlicht" value="5" />
|
||||||
|
<MiniStat label="Status" value="● Online" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Titel</span>
|
||||||
|
<span>URL</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Aktualisiert</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['Startseite', '/home', 'Veröffentlicht', '30.03.2026'],
|
||||||
|
['Über uns', '/ueber-uns', 'Entwurf', '28.03.2026'],
|
||||||
|
['Kurse', '/kurse', 'Veröffentlicht', '25.03.2026'],
|
||||||
|
['Kontakt', '/kontakt', 'Veröffentlicht', '20.03.2026'],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bookings',
|
||||||
|
label: 'Buchungen',
|
||||||
|
icon: <BedDouble className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Buchungen" value="36" />
|
||||||
|
<MiniStat label="Räume" value="8" />
|
||||||
|
<MiniStat label="Auslastung" value="64%" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="bg-muted/50 grid grid-cols-4 gap-2 border-b px-3 py-2 text-[10px] font-semibold">
|
||||||
|
<span>Raum</span>
|
||||||
|
<span>Gast</span>
|
||||||
|
<span>Check-in</span>
|
||||||
|
<span>Status</span>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['Seminarraum A', 'TV Musterstadt', '15.04.2026', 'Bestätigt'],
|
||||||
|
['Vereinsheim', 'Karin Bauer', '18.04.2026', 'Ausstehend'],
|
||||||
|
['Turnhalle', 'TSV Neustadt', '20.04.2026', 'Bestätigt'],
|
||||||
|
] as string[][]
|
||||||
|
).map((row, i) => (
|
||||||
|
<MiniRow key={i} cells={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'documents',
|
||||||
|
label: 'Dokumente',
|
||||||
|
icon: <FileText className={IC} />,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<MiniStat label="Vorlagen" value="5" />
|
||||||
|
<MiniStat label="Generiert" value="324" />
|
||||||
|
<MiniStat label="Zuletzt" value="Heute" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ name: 'Mitgliedsausweis', count: '1.247 generiert', type: 'PDF' },
|
||||||
|
{ name: 'Beitragsrechnung', count: '324 generiert', type: 'PDF' },
|
||||||
|
{ name: 'SEPA-Mandat', count: '890 generiert', type: 'PDF' },
|
||||||
|
{ name: 'Mitgliederliste', count: '12 Exporte', type: 'Excel' },
|
||||||
|
].map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.name}
|
||||||
|
className="bg-background flex items-center justify-between rounded-lg border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium">{d.name}</div>
|
||||||
|
<div className="text-muted-foreground text-[10px]">
|
||||||
|
{d.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[9px]">
|
||||||
|
{d.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function FeatureCarousel() {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const slide = SLIDES[active]!;
|
||||||
|
|
||||||
|
const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
|
||||||
|
const prev = useCallback(
|
||||||
|
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(next, 6000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [next]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto w-full max-w-5xl">
|
||||||
|
<div
|
||||||
|
className="bg-primary/10 absolute inset-0 -z-10 mx-auto max-w-3xl rounded-full blur-3xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="dark:border-primary/10 overflow-hidden rounded-2xl border border-gray-200 shadow-2xl">
|
||||||
|
{/* Browser chrome */}
|
||||||
|
<div className="bg-muted/80 flex items-center gap-2 border-b px-4 py-2.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-red-400" />
|
||||||
|
<div className="h-3 w-3 rounded-full bg-amber-400" />
|
||||||
|
<div className="h-3 w-3 rounded-full bg-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-background mx-auto flex items-center gap-1.5 rounded-md px-4 py-1 text-xs">
|
||||||
|
<Lock className="text-muted-foreground/40 h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
myeasycms.de/home/mein-verein
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout */}
|
||||||
|
<div className="bg-background flex" style={{ minHeight: 380 }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="bg-muted/30 hidden w-40 shrink-0 border-r p-2 md:block">
|
||||||
|
<div className="text-foreground mb-3 px-2 text-[10px] font-bold">
|
||||||
|
MYeasyCMS
|
||||||
|
</div>
|
||||||
|
{SLIDES.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[10px] transition-colors',
|
||||||
|
i === active
|
||||||
|
? 'bg-primary/10 text-primary font-semibold'
|
||||||
|
: 'text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.icon}
|
||||||
|
<span className="truncate">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 p-5">
|
||||||
|
<div className="text-muted-foreground mb-4 flex items-center gap-1 text-[10px]">
|
||||||
|
<span>Home</span>
|
||||||
|
<span>›</span>
|
||||||
|
<span>Mein Verein</span>
|
||||||
|
<span>›</span>
|
||||||
|
<span className="text-foreground font-medium">{slide.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="animate-in fade-in slide-in-from-right-2 duration-300"
|
||||||
|
key={slide.id}
|
||||||
|
>
|
||||||
|
{slide.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom tabs */}
|
||||||
|
<div className="border-t">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={prev}
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
aria-label="Vorheriges Feature"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="scrollbar-none flex min-w-0 flex-1 items-center justify-center gap-1.5 overflow-x-auto">
|
||||||
|
{SLIDES.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-1 rounded-full px-3 py-1.5 text-[11px] transition-colors',
|
||||||
|
i === active
|
||||||
|
? 'bg-primary text-primary-foreground font-semibold'
|
||||||
|
: 'text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.icon}
|
||||||
|
<span className="hidden lg:inline">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={next}
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
aria-label="Nächstes Feature"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,37 +2,44 @@ import { Footer } from '@kit/ui/marketing';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '~/components/app-logo';
|
||||||
import appConfig from '~/config/app.config';
|
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<Footer
|
<Footer
|
||||||
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
logo={<AppLogo className="w-[120px] md:w-[140px]" />}
|
||||||
description={<Trans i18nKey="marketing.footerDescription" />}
|
description={<Trans i18nKey="marketing.footerDescription" />}
|
||||||
copyright={
|
copyright={
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="marketing.copyright"
|
i18nKey="marketing.copyright"
|
||||||
values={{
|
values={{
|
||||||
product: appConfig.name,
|
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sections={[
|
sections={[
|
||||||
{
|
|
||||||
heading: <Trans i18nKey="marketing.about" />,
|
|
||||||
links: [
|
|
||||||
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
|
|
||||||
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
heading: <Trans i18nKey="marketing.product" />,
|
heading: <Trans i18nKey="marketing.product" />,
|
||||||
links: [
|
links: [
|
||||||
|
{
|
||||||
|
href: '/pricing',
|
||||||
|
label: <Trans i18nKey="marketing.pricing" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/docs',
|
href: '/docs',
|
||||||
label: <Trans i18nKey="marketing.documentation" />,
|
label: <Trans i18nKey="marketing.documentation" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/changelog',
|
||||||
|
label: <Trans i18nKey="marketing.changelog" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing.about" />,
|
||||||
|
links: [
|
||||||
|
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
|
||||||
|
{ href: '/faq', label: <Trans i18nKey="marketing.faq" /> },
|
||||||
|
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ const links = {
|
|||||||
label: 'marketing.blog',
|
label: 'marketing.blog',
|
||||||
path: '/blog',
|
path: '/blog',
|
||||||
},
|
},
|
||||||
Changelog: {
|
|
||||||
label: 'marketing.changelog',
|
|
||||||
path: '/changelog',
|
|
||||||
},
|
|
||||||
Docs: {
|
Docs: {
|
||||||
label: 'marketing.documentation',
|
label: 'marketing.documentation',
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
@@ -34,6 +30,10 @@ const links = {
|
|||||||
label: 'marketing.faq',
|
label: 'marketing.faq',
|
||||||
path: '/faq',
|
path: '/faq',
|
||||||
},
|
},
|
||||||
|
Contact: {
|
||||||
|
label: 'marketing.contact',
|
||||||
|
path: '/contact',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SiteNavigation() {
|
export function SiteNavigation() {
|
||||||
|
|||||||
@@ -19,33 +19,56 @@ export const generateMetadata = async () => {
|
|||||||
async function FAQPage() {
|
async function FAQPage() {
|
||||||
const t = await getTranslations('marketing');
|
const t = await getTranslations('marketing');
|
||||||
|
|
||||||
// replace this content with translations
|
|
||||||
const faqItems = [
|
const faqItems = [
|
||||||
{
|
{
|
||||||
// or: t('faq.question1')
|
question: 'Was ist MYeasyCMS?',
|
||||||
question: `Do you offer a free trial?`,
|
answer:
|
||||||
// or: t('faq.answer1')
|
'MYeasyCMS ist eine webbasierte Vereins- und Verbandsverwaltung. Sie verwalten Mitglieder, Beiträge, Kurse, Veranstaltungen, Finanzen und mehr — alles über den Browser, ohne Software-Installation.',
|
||||||
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: `Can I cancel my subscription?`,
|
question: 'Für welche Vereine ist MYeasyCMS geeignet?',
|
||||||
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
|
answer:
|
||||||
|
'MYeasyCMS eignet sich für alle Arten von Vereinen: Sportvereine, Fischereivereine, Kulturvereine, Bildungseinrichtungen, Verbände und kommunale Organisationen. Über 90 Vereine in Bayern arbeiten bereits mit der Plattform.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: `Where can I find my invoices?`,
|
question: 'Gibt es einen kostenlosen Testzugang?',
|
||||||
answer: `You can find your invoices in your account settings.`,
|
answer:
|
||||||
|
'Ja, wir richten Ihnen einen kostenlosen Testzugang ein und führen Sie persönlich durch die Plattform. Rufen Sie uns an unter 09451 9499-09 oder nutzen Sie das Kontaktformular.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: `What payment methods do you accept?`,
|
question: 'Muss ich Software installieren?',
|
||||||
answer: `We accept all major credit cards and PayPal.`,
|
answer:
|
||||||
|
'Nein. MYeasyCMS ist vollständig webbasiert und läuft in jedem modernen Browser (Chrome, Firefox, Safari, Edge). Es gibt keinen Download, keine Installation und keine manuellen Updates.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: `Can I upgrade or downgrade my plan?`,
|
question: 'Wie viele Benutzer kann ich anlegen?',
|
||||||
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
|
answer:
|
||||||
|
'Es gibt keine Begrenzung der Benutzeranzahl. Alle Tarife enthalten unbegrenzte Zugänge für Vorstandsmitglieder, Kassenwarte, Kursleiter und andere Mitarbeiter.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: `Do you offer discounts for non-profits?`,
|
question: 'Ist MYeasyCMS DSGVO-konform?',
|
||||||
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
|
answer:
|
||||||
|
'Ja. Alle Daten liegen auf Servern in Deutschland. Rollenbasierte Zugriffsrechte, verschlüsselte Datenübertragung und Funktionen für Auskunfts- und Löschungsanfragen sind integriert. Ein Auftragsverarbeitungsvertrag (AVV) wird bereitgestellt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Kann ich bestehende Mitgliederlisten importieren?',
|
||||||
|
answer:
|
||||||
|
'Ja. Der Import-Assistent unterstützt Excel- und CSV-Dateien. Sie laden Ihre bestehende Mitgliederliste hoch, ordnen die Spalten zu und importieren die Daten. Bei Bedarf unterstützt Sie das Com.BISS-Team persönlich.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Wie funktioniert der SEPA-Beitragseinzug?',
|
||||||
|
answer:
|
||||||
|
'MYeasyCMS erzeugt SEPA-XML-Dateien (pain.008) aus den hinterlegten Mandaten und offenen Beiträgen. Sie laden die Datei herunter und reichen sie im Online-Banking Ihrer Bank ein. Mandatsverwaltung und IBAN-Prüfung sind integriert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Wie erreiche ich den Support?',
|
||||||
|
answer:
|
||||||
|
'Direkt und persönlich — kein anonymes Ticketsystem. Telefon: 09451 9499-09, E-Mail: info@combiss.de. Sie sprechen mit den Menschen, die Ihre Software entwickeln.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Was kostet MYeasyCMS?',
|
||||||
|
answer:
|
||||||
|
'Die Preise richten sich nach der Vereinsgröße (Mitgliederzahl). Alle Module und Funktionen sind in jedem Tarif enthalten. Für ein individuelles Angebot kontaktieren Sie uns — wir beraten Sie gerne.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
BookOpenIcon,
|
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
@@ -27,15 +25,20 @@ import {
|
|||||||
EcosystemShowcase,
|
EcosystemShowcase,
|
||||||
FeatureShowcase,
|
FeatureShowcase,
|
||||||
FeatureShowcaseIconContainer,
|
FeatureShowcaseIconContainer,
|
||||||
|
GradientText,
|
||||||
Hero,
|
Hero,
|
||||||
Pill,
|
Pill,
|
||||||
SecondaryHero,
|
SecondaryHero,
|
||||||
} from '@kit/ui/marketing';
|
} from '@kit/ui/marketing';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
import billingConfig from '~/config/billing.config';
|
import billingConfig from '~/config/billing.config';
|
||||||
import pathsConfig from '~/config/paths.config';
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
import { AnimateOnScroll } from './_components/animate-on-scroll';
|
||||||
|
import { FeatureCarousel } from './_components/feature-carousel';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={'mt-4 flex flex-col space-y-24 py-14 lg:space-y-36'}>
|
<div className={'mt-4 flex flex-col space-y-24 py-14 lg:space-y-36'}>
|
||||||
@@ -51,7 +54,10 @@ function Home() {
|
|||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<span className="text-secondary-foreground">
|
<span className="text-secondary-foreground">
|
||||||
<Trans i18nKey={'marketing.heroTitle'} />
|
<Trans i18nKey={'marketing.heroTitleLine1'} />{' '}
|
||||||
|
<GradientText className="from-primary to-primary/60">
|
||||||
|
<Trans i18nKey={'marketing.heroTitleLine2'} />
|
||||||
|
</GradientText>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
@@ -61,288 +67,334 @@ function Home() {
|
|||||||
}
|
}
|
||||||
cta={<MainCallToActionButton />}
|
cta={<MainCallToActionButton />}
|
||||||
image={
|
image={
|
||||||
<Image
|
<div className="relative">
|
||||||
priority
|
<div
|
||||||
className={
|
className="bg-primary/10 absolute inset-0 -z-10 mx-auto max-w-3xl rounded-full blur-3xl"
|
||||||
'dark:border-primary/10 w-full rounded-2xl border border-gray-200 shadow-2xl'
|
aria-hidden="true"
|
||||||
}
|
/>
|
||||||
width={3558}
|
<FeatureCarousel />
|
||||||
height={2222}
|
</div>
|
||||||
src={`/images/dashboard.webp`}
|
|
||||||
alt={`MyEasyCMS Dashboard`}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trust Indicators */}
|
{/* Stats Bar */}
|
||||||
<div className={'container mx-auto'}>
|
<AnimateOnScroll>
|
||||||
<div className="flex flex-col items-center gap-8">
|
<div className={'container mx-auto'}>
|
||||||
<p className="text-muted-foreground text-sm font-medium uppercase tracking-widest">
|
<div className="border-border border-y py-8">
|
||||||
<Trans i18nKey={'marketing.trustedBy'} />
|
<p className="text-muted-foreground mb-6 text-center text-sm font-medium tracking-widest uppercase">
|
||||||
</p>
|
<Trans i18nKey={'marketing.trustedBy'} />
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
<div className="reveal-stagger divide-border flex flex-wrap items-center justify-center divide-x">
|
||||||
<TrustItem icon={UsersIcon} label="marketing.trustAssociations" />
|
<StatItem value="69,000+" labelKey="marketing.statMembers" />
|
||||||
<TrustItem
|
<StatItem value="90+" labelKey="marketing.statOrganizations" />
|
||||||
icon={GraduationCapIcon}
|
<StatItem value="22" labelKey="marketing.statYears" />
|
||||||
label="marketing.trustSchools"
|
<StatItem value="3" labelKey="marketing.statFederations" />
|
||||||
/>
|
</div>
|
||||||
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
|
|
||||||
<TrustItem
|
|
||||||
icon={GlobeIcon}
|
|
||||||
label="marketing.trustOrganizations"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* Core Modules Feature Grid */}
|
{/* Core Modules Feature Grid */}
|
||||||
<div className={'container mx-auto'}>
|
<AnimateOnScroll>
|
||||||
<div className={'py-4 xl:py-8'}>
|
<div className={'container mx-auto'}>
|
||||||
<FeatureShowcase
|
<div className={'py-4 xl:py-8'}>
|
||||||
heading={
|
<FeatureShowcase
|
||||||
<>
|
heading={
|
||||||
<b className="font-medium tracking-tight dark:text-white">
|
<>
|
||||||
<Trans i18nKey={'marketing.featuresHeading'} />
|
<b className="font-medium tracking-tight dark:text-white">
|
||||||
</b>
|
<Trans i18nKey={'marketing.featuresHeading'} />
|
||||||
.{' '}
|
</b>
|
||||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
.{' '}
|
||||||
<Trans i18nKey={'marketing.featuresSubheading'} />
|
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||||
</span>
|
<Trans i18nKey={'marketing.featuresSubheading'} />
|
||||||
</>
|
</span>
|
||||||
}
|
</>
|
||||||
icon={
|
}
|
||||||
<FeatureShowcaseIconContainer>
|
icon={
|
||||||
<LayoutDashboardIcon className="h-4 w-4" />
|
<FeatureShowcaseIconContainer>
|
||||||
<span>
|
<LayoutDashboardIcon className="h-4 w-4" />
|
||||||
<Trans i18nKey={'marketing.featuresLabel'} />
|
<span>
|
||||||
</span>
|
<Trans i18nKey={'marketing.featuresLabel'} />
|
||||||
</FeatureShowcaseIconContainer>
|
</span>
|
||||||
}
|
</FeatureShowcaseIconContainer>
|
||||||
>
|
}
|
||||||
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
>
|
||||||
<IconFeatureCard
|
<div className="reveal-stagger mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
icon={UsersIcon}
|
<IconFeatureCard
|
||||||
titleKey="marketing.featureMembersTitle"
|
icon={UsersIcon}
|
||||||
descKey="marketing.featureMembersDesc"
|
titleKey="marketing.featureMembersTitle"
|
||||||
|
descKey="marketing.featureMembersDesc"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={GraduationCapIcon}
|
||||||
|
titleKey="marketing.featureCoursesTitle"
|
||||||
|
descKey="marketing.featureCoursesDesc"
|
||||||
|
accentBg="bg-chart-1/10"
|
||||||
|
accentText="text-chart-1"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={BedDoubleIcon}
|
||||||
|
titleKey="marketing.featureBookingsTitle"
|
||||||
|
descKey="marketing.featureBookingsDesc"
|
||||||
|
accentBg="bg-chart-2/10"
|
||||||
|
accentText="text-chart-2"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={CalendarIcon}
|
||||||
|
titleKey="marketing.featureEventsTitle"
|
||||||
|
descKey="marketing.featureEventsDesc"
|
||||||
|
accentBg="bg-chart-3/10"
|
||||||
|
accentText="text-chart-3"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={WalletIcon}
|
||||||
|
titleKey="marketing.featureFinanceTitle"
|
||||||
|
descKey="marketing.featureFinanceDesc"
|
||||||
|
accentBg="bg-chart-4/10"
|
||||||
|
accentText="text-chart-4"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={MailIcon}
|
||||||
|
titleKey="marketing.featureNewsletterTitle"
|
||||||
|
descKey="marketing.featureNewsletterDesc"
|
||||||
|
accentBg="bg-chart-5/10"
|
||||||
|
accentText="text-chart-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FeatureShowcase>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateOnScroll>
|
||||||
|
|
||||||
|
{/* Testimonials */}
|
||||||
|
<AnimateOnScroll>
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
|
||||||
|
<Trans i18nKey={'marketing.testimonialsHeading'} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
||||||
|
<Trans i18nKey={'marketing.testimonialsSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="reveal-stagger grid w-full grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
<TestimonialCard
|
||||||
|
quoteKey="marketing.testimonial1Quote"
|
||||||
|
nameKey="marketing.testimonial1Name"
|
||||||
|
roleKey="marketing.testimonial1Role"
|
||||||
/>
|
/>
|
||||||
<IconFeatureCard
|
<TestimonialCard
|
||||||
icon={GraduationCapIcon}
|
quoteKey="marketing.testimonial2Quote"
|
||||||
titleKey="marketing.featureCoursesTitle"
|
nameKey="marketing.testimonial2Name"
|
||||||
descKey="marketing.featureCoursesDesc"
|
roleKey="marketing.testimonial2Role"
|
||||||
/>
|
/>
|
||||||
<IconFeatureCard
|
<TestimonialCard
|
||||||
icon={BedDoubleIcon}
|
quoteKey="marketing.testimonial3Quote"
|
||||||
titleKey="marketing.featureBookingsTitle"
|
nameKey="marketing.testimonial3Name"
|
||||||
descKey="marketing.featureBookingsDesc"
|
roleKey="marketing.testimonial3Role"
|
||||||
/>
|
|
||||||
<IconFeatureCard
|
|
||||||
icon={CalendarIcon}
|
|
||||||
titleKey="marketing.featureEventsTitle"
|
|
||||||
descKey="marketing.featureEventsDesc"
|
|
||||||
/>
|
|
||||||
<IconFeatureCard
|
|
||||||
icon={WalletIcon}
|
|
||||||
titleKey="marketing.featureFinanceTitle"
|
|
||||||
descKey="marketing.featureFinanceDesc"
|
|
||||||
/>
|
|
||||||
<IconFeatureCard
|
|
||||||
icon={MailIcon}
|
|
||||||
titleKey="marketing.featureNewsletterTitle"
|
|
||||||
descKey="marketing.featureNewsletterDesc"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FeatureShowcase>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* Dashboard Showcase */}
|
|
||||||
<div className={'container mx-auto'}>
|
|
||||||
<EcosystemShowcase
|
|
||||||
heading={<Trans i18nKey={'marketing.showcaseHeading'} />}
|
|
||||||
description={<Trans i18nKey={'marketing.showcaseDescription'} />}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="rounded-lg shadow-lg"
|
|
||||||
src={'/images/dashboard.webp'}
|
|
||||||
alt="MyEasyCMS Dashboard"
|
|
||||||
width={1200}
|
|
||||||
height={800}
|
|
||||||
/>
|
|
||||||
</EcosystemShowcase>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Features Row */}
|
{/* Additional Features Row */}
|
||||||
<div className={'container mx-auto'}>
|
<AnimateOnScroll>
|
||||||
<div className={'py-4 xl:py-8'}>
|
<div className={'container mx-auto'}>
|
||||||
<FeatureShowcase
|
<div className={'py-4 xl:py-8'}>
|
||||||
heading={
|
<FeatureShowcase
|
||||||
<>
|
heading={
|
||||||
<b className="font-medium tracking-tight dark:text-white">
|
<>
|
||||||
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
|
<b className="font-medium tracking-tight dark:text-white">
|
||||||
</b>
|
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
|
||||||
.{' '}
|
</b>
|
||||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
.{' '}
|
||||||
<Trans
|
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||||
i18nKey={'marketing.additionalFeaturesSubheading'}
|
<Trans i18nKey={'marketing.additionalFeaturesSubheading'} />
|
||||||
/>
|
</span>
|
||||||
</span>
|
</>
|
||||||
</>
|
}
|
||||||
}
|
icon={
|
||||||
icon={
|
<FeatureShowcaseIconContainer>
|
||||||
<FeatureShowcaseIconContainer>
|
<ZapIcon className="h-4 w-4" />
|
||||||
<ZapIcon className="h-4 w-4" />
|
<span>
|
||||||
<span>
|
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
|
||||||
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
|
</span>
|
||||||
</span>
|
</FeatureShowcaseIconContainer>
|
||||||
</FeatureShowcaseIconContainer>
|
}
|
||||||
}
|
>
|
||||||
>
|
<div className="reveal-stagger mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
<IconFeatureCard
|
||||||
<IconFeatureCard
|
icon={FileTextIcon}
|
||||||
icon={FileTextIcon}
|
titleKey="marketing.featureDocumentsTitle"
|
||||||
titleKey="marketing.featureDocumentsTitle"
|
descKey="marketing.featureDocumentsDesc"
|
||||||
descKey="marketing.featureDocumentsDesc"
|
accentBg="bg-chart-1/10"
|
||||||
/>
|
accentText="text-chart-1"
|
||||||
<IconFeatureCard
|
/>
|
||||||
icon={GlobeIcon}
|
<IconFeatureCard
|
||||||
titleKey="marketing.featureSiteBuilderTitle"
|
icon={GlobeIcon}
|
||||||
descKey="marketing.featureSiteBuilderDesc"
|
titleKey="marketing.featureSiteBuilderTitle"
|
||||||
/>
|
descKey="marketing.featureSiteBuilderDesc"
|
||||||
<IconFeatureCard
|
accentBg="bg-chart-2/10"
|
||||||
icon={LayoutDashboardIcon}
|
accentText="text-chart-2"
|
||||||
titleKey="marketing.featureModulesTitle"
|
/>
|
||||||
descKey="marketing.featureModulesDesc"
|
<IconFeatureCard
|
||||||
/>
|
icon={LayoutDashboardIcon}
|
||||||
</div>
|
titleKey="marketing.featureModulesTitle"
|
||||||
</FeatureShowcase>
|
descKey="marketing.featureModulesDesc"
|
||||||
|
accentBg="bg-chart-3/10"
|
||||||
|
accentText="text-chart-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FeatureShowcase>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* Why Choose Us Section */}
|
{/* Why Choose Us Section */}
|
||||||
<div className={'container mx-auto'}>
|
<AnimateOnScroll>
|
||||||
<EcosystemShowcase
|
<div className={'container mx-auto'}>
|
||||||
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
|
<EcosystemShowcase
|
||||||
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
|
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
|
||||||
textPosition="right"
|
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
|
||||||
>
|
textPosition="right"
|
||||||
<div className="flex flex-col gap-6">
|
className="border-primary/10 rounded-xl border"
|
||||||
<WhyItem
|
>
|
||||||
icon={SmartphoneIcon}
|
<div className="flex flex-col gap-6 text-left">
|
||||||
titleKey="marketing.whyResponsiveTitle"
|
<WhyItem
|
||||||
descKey="marketing.whyResponsiveDesc"
|
icon={SmartphoneIcon}
|
||||||
/>
|
titleKey="marketing.whyResponsiveTitle"
|
||||||
<WhyItem
|
descKey="marketing.whyResponsiveDesc"
|
||||||
icon={LockIcon}
|
/>
|
||||||
titleKey="marketing.whySecureTitle"
|
<WhyItem
|
||||||
descKey="marketing.whySecureDesc"
|
icon={LockIcon}
|
||||||
/>
|
titleKey="marketing.whySecureTitle"
|
||||||
<WhyItem
|
descKey="marketing.whySecureDesc"
|
||||||
icon={HeadsetIcon}
|
/>
|
||||||
titleKey="marketing.whySupportTitle"
|
<WhyItem
|
||||||
descKey="marketing.whySupportDesc"
|
icon={HeadsetIcon}
|
||||||
/>
|
titleKey="marketing.whySupportTitle"
|
||||||
<WhyItem
|
descKey="marketing.whySupportDesc"
|
||||||
icon={ShieldCheckIcon}
|
/>
|
||||||
titleKey="marketing.whyGdprTitle"
|
<WhyItem
|
||||||
descKey="marketing.whyGdprDesc"
|
icon={ShieldCheckIcon}
|
||||||
/>
|
titleKey="marketing.whyGdprTitle"
|
||||||
</div>
|
descKey="marketing.whyGdprDesc"
|
||||||
</EcosystemShowcase>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</EcosystemShowcase>
|
||||||
|
</div>
|
||||||
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<div className="container mx-auto">
|
<AnimateOnScroll>
|
||||||
<div className="flex flex-col items-center gap-12">
|
<div className="container mx-auto">
|
||||||
<div className="text-center">
|
<div className="flex flex-col items-center gap-12">
|
||||||
<h2 className="text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
|
<div className="text-center">
|
||||||
<Trans i18nKey={'marketing.howItWorksHeading'} />
|
<h2 className="text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
|
||||||
</h2>
|
<Trans i18nKey={'marketing.howItWorksHeading'} />
|
||||||
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
</h2>
|
||||||
<Trans i18nKey={'marketing.howItWorksSubheading'} />
|
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
||||||
</p>
|
<Trans i18nKey={'marketing.howItWorksSubheading'} />
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full grid-cols-1 gap-8 md:grid-cols-3">
|
<div className="relative grid w-full grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
<StepCard
|
<div
|
||||||
step="01"
|
className="border-primary/30 absolute top-10 right-[16.67%] left-[16.67%] hidden h-px border-t border-dashed md:block"
|
||||||
titleKey="marketing.howStep1Title"
|
aria-hidden="true"
|
||||||
descKey="marketing.howStep1Desc"
|
/>
|
||||||
/>
|
<StepCard
|
||||||
<StepCard
|
step="01"
|
||||||
step="02"
|
titleKey="marketing.howStep1Title"
|
||||||
titleKey="marketing.howStep2Title"
|
descKey="marketing.howStep1Desc"
|
||||||
descKey="marketing.howStep2Desc"
|
/>
|
||||||
/>
|
<StepCard
|
||||||
<StepCard
|
step="02"
|
||||||
step="03"
|
titleKey="marketing.howStep2Title"
|
||||||
titleKey="marketing.howStep3Title"
|
descKey="marketing.howStep2Desc"
|
||||||
descKey="marketing.howStep3Desc"
|
/>
|
||||||
/>
|
<StepCard
|
||||||
|
step="03"
|
||||||
|
titleKey="marketing.howStep3Title"
|
||||||
|
descKey="marketing.howStep3Desc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* Pricing Section */}
|
{/* Pricing Section */}
|
||||||
<div className={'container mx-auto'}>
|
<AnimateOnScroll>
|
||||||
<div
|
<div className={'container mx-auto'}>
|
||||||
className={
|
<div
|
||||||
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
|
className={
|
||||||
}
|
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
|
||||||
>
|
|
||||||
<SecondaryHero
|
|
||||||
pill={
|
|
||||||
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
|
|
||||||
<Trans i18nKey={'marketing.pricingPillText'} />
|
|
||||||
</Pill>
|
|
||||||
}
|
}
|
||||||
heading={<Trans i18nKey={'marketing.pricingHeading'} />}
|
>
|
||||||
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
|
<SecondaryHero
|
||||||
/>
|
pill={
|
||||||
|
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
|
||||||
<div className={'w-full'}>
|
<Trans i18nKey={'marketing.pricingPillText'} />
|
||||||
<PricingTable
|
</Pill>
|
||||||
config={billingConfig}
|
}
|
||||||
paths={{
|
heading={
|
||||||
signUp: pathsConfig.auth.signUp,
|
<GradientText className="from-primary to-primary/60">
|
||||||
return: pathsConfig.app.home,
|
<Trans i18nKey={'marketing.pricingHeading'} />
|
||||||
}}
|
</GradientText>
|
||||||
|
}
|
||||||
|
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={'w-full'}>
|
||||||
|
<PricingTable
|
||||||
|
config={billingConfig}
|
||||||
|
paths={{
|
||||||
|
signUp: pathsConfig.auth.signUp,
|
||||||
|
return: pathsConfig.app.home,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<div className="container mx-auto">
|
<AnimateOnScroll>
|
||||||
<div className="bg-primary/5 flex flex-col items-center gap-8 rounded-2xl border p-12 text-center lg:p-16">
|
<div className="container mx-auto">
|
||||||
<h2 className="max-w-3xl text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
|
<div className="ring-primary/10 from-primary/10 via-background to-primary/5 flex flex-col items-center gap-8 rounded-2xl border bg-gradient-to-br p-12 text-center ring-1 lg:p-16">
|
||||||
<Trans i18nKey={'marketing.ctaHeading'} />
|
<h2 className="max-w-3xl text-3xl font-medium tracking-tight xl:text-5xl dark:text-white">
|
||||||
</h2>
|
<GradientText className="from-primary to-primary/60">
|
||||||
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
|
<Trans i18nKey={'marketing.ctaHeading'} />
|
||||||
<Trans i18nKey={'marketing.ctaDescription'} />
|
</GradientText>
|
||||||
</p>
|
</h2>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
|
||||||
<CtaButton className="h-12 px-8 text-base">
|
<Trans i18nKey={'marketing.ctaDescription'} />
|
||||||
<Link href={'/auth/sign-up'}>
|
</p>
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
|
<CtaButton className="h-14 px-10 text-lg">
|
||||||
<ArrowRightIcon className="h-4 w-4" />
|
<Link href={'/auth/sign-up'}>
|
||||||
</span>
|
<span className="flex items-center gap-2">
|
||||||
</Link>
|
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
|
||||||
</CtaButton>
|
<ArrowRightIcon className="h-5 w-5" />
|
||||||
<CtaButton variant={'outline'} className="h-12 px-8 text-base">
|
</span>
|
||||||
<Link href={'/contact'}>
|
</Link>
|
||||||
<Trans i18nKey={'marketing.ctaButtonSecondary'} />
|
</CtaButton>
|
||||||
</Link>
|
<CtaButton variant={'outline'} className="h-14 px-10 text-lg">
|
||||||
</CtaButton>
|
<Link href={'/contact'}>
|
||||||
|
<Trans i18nKey={'marketing.ctaButtonSecondary'} />
|
||||||
|
</Link>
|
||||||
|
</CtaButton>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
<Trans i18nKey={'marketing.ctaNote'} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
<Trans i18nKey={'marketing.ctaNote'} />
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateOnScroll>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -352,7 +404,7 @@ export default Home;
|
|||||||
function MainCallToActionButton() {
|
function MainCallToActionButton() {
|
||||||
return (
|
return (
|
||||||
<div className={'flex space-x-2.5'}>
|
<div className={'flex space-x-2.5'}>
|
||||||
<CtaButton className="h-10 text-sm">
|
<CtaButton className="h-12 px-8 text-base shadow-lg">
|
||||||
<Link href={'/auth/sign-up'}>
|
<Link href={'/auth/sign-up'}>
|
||||||
<span className={'flex items-center space-x-0.5'}>
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
<span>
|
<span>
|
||||||
@@ -369,7 +421,7 @@ function MainCallToActionButton() {
|
|||||||
</Link>
|
</Link>
|
||||||
</CtaButton>
|
</CtaButton>
|
||||||
|
|
||||||
<CtaButton variant={'link'} className="h-10 text-sm">
|
<CtaButton variant={'link'} className="h-12 px-8 text-base">
|
||||||
<Link href={'/pricing'}>
|
<Link href={'/pricing'}>
|
||||||
<Trans i18nKey={'common.pricing'} />
|
<Trans i18nKey={'common.pricing'} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -382,11 +434,20 @@ function IconFeatureCard(props: {
|
|||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
descKey: string;
|
descKey: string;
|
||||||
|
accentBg?: string;
|
||||||
|
accentText?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted/50 flex flex-col gap-3 rounded p-6">
|
<div className="reveal bg-muted/50 hover:border-primary/20 flex flex-col gap-3 rounded-xl border border-transparent p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-md">
|
||||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
<div
|
||||||
<props.icon className="text-primary h-5 w-5" />
|
className={cn(
|
||||||
|
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||||
|
props.accentBg ?? 'bg-primary/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<props.icon
|
||||||
|
className={cn('h-5 w-5', props.accentText ?? 'text-primary')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-medium">
|
<h4 className="text-lg font-medium">
|
||||||
<Trans i18nKey={props.titleKey} />
|
<Trans i18nKey={props.titleKey} />
|
||||||
@@ -398,16 +459,39 @@ function IconFeatureCard(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrustItem(props: {
|
function StatItem(props: { value: string; labelKey: string }) {
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
return (
|
||||||
label: string;
|
<div className="reveal flex flex-col items-center gap-1 px-6 py-4">
|
||||||
|
<span className="text-primary text-3xl font-bold tracking-tight lg:text-4xl">
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">
|
||||||
|
<Trans i18nKey={props.labelKey} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestimonialCard(props: {
|
||||||
|
quoteKey: string;
|
||||||
|
nameKey: string;
|
||||||
|
roleKey: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex items-center gap-2.5">
|
<div className="reveal border-border bg-card flex flex-col gap-4 rounded-xl border p-6 shadow-sm">
|
||||||
<props.icon className="h-5 w-5" />
|
<p className="text-secondary-foreground text-sm leading-relaxed italic">
|
||||||
<span className="text-sm font-medium">
|
“
|
||||||
<Trans i18nKey={props.label} />
|
<Trans i18nKey={props.quoteKey} />
|
||||||
</span>
|
”
|
||||||
|
</p>
|
||||||
|
<div className="border-border border-t pt-4">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans i18nKey={props.nameKey} />
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
<Trans i18nKey={props.roleKey} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -419,7 +503,7 @@ function WhyItem(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
<div className="ring-primary/20 bg-primary/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-xl ring-1">
|
||||||
<props.icon className="text-primary h-5 w-5" />
|
<props.icon className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -436,8 +520,10 @@ function WhyItem(props: {
|
|||||||
|
|
||||||
function StepCard(props: { step: string; titleKey: string; descKey: string }) {
|
function StepCard(props: { step: string; titleKey: string; descKey: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted/50 relative flex flex-col gap-4 rounded-lg p-6">
|
<div className="reveal border-border bg-card relative flex flex-col items-center gap-4 rounded-xl border p-8 text-center shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-md">
|
||||||
<span className="text-primary/20 text-6xl font-bold">{props.step}</span>
|
<div className="bg-primary text-primary-foreground shadow-primary/20 relative z-10 flex h-14 w-14 items-center justify-center rounded-full text-xl font-bold shadow-lg">
|
||||||
|
{props.step}
|
||||||
|
</div>
|
||||||
<h3 className="text-secondary-foreground text-xl font-medium">
|
<h3 className="text-secondary-foreground text-xl font-medium">
|
||||||
<Trans i18nKey={props.titleKey} />
|
<Trans i18nKey={props.titleKey} />
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Check, ExternalLink, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Competitor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
getPrice: (m: number) => number | null;
|
||||||
|
color: string;
|
||||||
|
maxMembers?: number;
|
||||||
|
verbandExtra: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPETITORS: Competitor[] = [
|
||||||
|
{
|
||||||
|
id: 'sewobe',
|
||||||
|
name: 'SEWOBE VereinsManager',
|
||||||
|
note: 'VerbandsMANAGER separat',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 500) return 30;
|
||||||
|
if (m <= 1000) return 49;
|
||||||
|
if (m <= 2000) return 99;
|
||||||
|
if (m <= 3000) return 159;
|
||||||
|
if (m <= 5000) return 269;
|
||||||
|
if (m <= 7500) return 369;
|
||||||
|
return 469;
|
||||||
|
},
|
||||||
|
color: 'bg-red-500',
|
||||||
|
verbandExtra:
|
||||||
|
'VerbandsMANAGER: separates Produkt, Preis auf Anfrage (deutlich teurer)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'easyverein',
|
||||||
|
name: 'easyVerein Professional',
|
||||||
|
note: 'Dachverbandslösung extra',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 100) return 20;
|
||||||
|
if (m <= 250) return 31;
|
||||||
|
if (m <= 500) return 49;
|
||||||
|
if (m <= 1000) return 79;
|
||||||
|
if (m <= 2000) return 129;
|
||||||
|
if (m <= 5000) return 249;
|
||||||
|
return 399;
|
||||||
|
},
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
verbandExtra:
|
||||||
|
'Dachverbandslösung: jede Instanz eigene kostenpflichtige Lizenz',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clubdesk',
|
||||||
|
name: 'ClubDesk',
|
||||||
|
note: 'Server in der Schweiz',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 50) return 0;
|
||||||
|
if (m <= 100) return 10;
|
||||||
|
if (m <= 250) return 15;
|
||||||
|
if (m <= 500) return 25;
|
||||||
|
if (m <= 1000) return 33;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
maxMembers: 1000,
|
||||||
|
verbandExtra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wiso',
|
||||||
|
name: 'WISO MeinVerein Web',
|
||||||
|
note: 'Buhl / ZDF-WISO Marke',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 100) return 10;
|
||||||
|
if (m <= 250) return 15;
|
||||||
|
if (m <= 500) return 25;
|
||||||
|
if (m <= 1000) return 35;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
color: 'bg-violet-500',
|
||||||
|
maxMembers: 1000,
|
||||||
|
verbandExtra: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ name: 'Starter', price: 29, maxMembers: 250 },
|
||||||
|
{ name: 'Pro', price: 59, maxMembers: 1000 },
|
||||||
|
{ name: 'Verband', price: 199, maxMembers: 10000 },
|
||||||
|
{ name: 'Enterprise', price: 349, maxMembers: 99999 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getTier(m: number) {
|
||||||
|
return TIERS.find((t) => m <= t.maxMembers) ?? TIERS[TIERS.length - 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return n.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Feature comparison data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FeatureValue = boolean | string;
|
||||||
|
|
||||||
|
interface FeatureRow {
|
||||||
|
label: string;
|
||||||
|
mcms: FeatureValue;
|
||||||
|
sewobe: FeatureValue;
|
||||||
|
easy: FeatureValue;
|
||||||
|
club: FeatureValue;
|
||||||
|
wiso: FeatureValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USP_FEATURES: FeatureRow[] = [
|
||||||
|
{
|
||||||
|
label: 'Verbandsmodul (Mehrebenen-Hierarchie)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Separates Produkt',
|
||||||
|
easy: 'Extra Lösung',
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Vereins-Website inklusive',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: false,
|
||||||
|
easy: false,
|
||||||
|
club: true,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kursverwaltung (Dozenten, Räume)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: false,
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SEPA-Lastschrift',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: true,
|
||||||
|
easy: true,
|
||||||
|
club: true,
|
||||||
|
wiso: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Persönlicher Telefon-Support',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Kostenpflichtig',
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unbegrenzte Benutzer (ab Pro)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: '5 inkl., +6€/User',
|
||||||
|
easy: '3–10 inkl.',
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server in Deutschland',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: true,
|
||||||
|
easy: true,
|
||||||
|
club: 'Schweiz',
|
||||||
|
wiso: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Individuelle Module',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Aufpreis',
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FeatureCell({ value }: { value: FeatureValue }) {
|
||||||
|
if (value === true) {
|
||||||
|
return <Check className="text-primary mx-auto h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === false) {
|
||||||
|
return <X className="text-destructive mx-auto h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceBar({
|
||||||
|
label,
|
||||||
|
note,
|
||||||
|
value,
|
||||||
|
maxValue,
|
||||||
|
color,
|
||||||
|
available,
|
||||||
|
maxMembers,
|
||||||
|
period,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
value: number;
|
||||||
|
maxValue: number;
|
||||||
|
color: string;
|
||||||
|
available: boolean;
|
||||||
|
maxMembers?: number;
|
||||||
|
period: 'month' | 'year';
|
||||||
|
}) {
|
||||||
|
const mult = period === 'year' ? 12 : 1;
|
||||||
|
const pct = available ? Math.min((value / (maxValue * 1.1)) * 100, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground flex items-baseline justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{label}{' '}
|
||||||
|
{note && (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">({note})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold">
|
||||||
|
{available ? (
|
||||||
|
<span className="text-destructive">{fmt(value * mult)} €</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">
|
||||||
|
nicht verfügbar ab {fmt(maxMembers ?? 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted h-6 overflow-hidden rounded-md">
|
||||||
|
{available ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-md transition-all duration-500',
|
||||||
|
color,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
minWidth: pct > 0 ? 4 : 0,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="bg-muted-foreground/5 h-full rounded-md"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(0,0,0,.03) 8px, rgba(0,0,0,.03) 16px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Calculator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function PricingCalculator() {
|
||||||
|
const [members, setMembers] = useState(500);
|
||||||
|
const [period, setPeriod] = useState<'month' | 'year'>('year');
|
||||||
|
|
||||||
|
const tier = useMemo(() => getTier(members), [members]);
|
||||||
|
const mult = period === 'year' ? 12 : 1;
|
||||||
|
|
||||||
|
const compPrices = useMemo(
|
||||||
|
() => COMPETITORS.map((c) => ({ ...c, p: c.getPrice(members) })),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxBar = useMemo(
|
||||||
|
() =>
|
||||||
|
Math.max(
|
||||||
|
tier.price,
|
||||||
|
...compPrices.filter((c) => c.p !== null).map((c) => c.p!),
|
||||||
|
),
|
||||||
|
[tier.price, compPrices],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bestSaving = useMemo(
|
||||||
|
() =>
|
||||||
|
compPrices
|
||||||
|
.filter((c) => c.p !== null && c.p > tier.price)
|
||||||
|
.sort((a, b) => b.p! - a.p!)[0] ?? null,
|
||||||
|
[compPrices, tier.price],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-4xl space-y-0">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="bg-primary rounded-t-2xl px-8 py-7">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
Preisvergleich
|
||||||
|
</Badge>
|
||||||
|
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
|
||||||
|
MYeasyCMS vs. Markt — was sparen Sie wirklich?
|
||||||
|
</h2>
|
||||||
|
<p className="text-primary-foreground/50 mt-1 text-sm">
|
||||||
|
Echte Preise von SEWOBE, easyVerein, ClubDesk und WISO MeinVerein.
|
||||||
|
Alle Preise netto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<Card className="rounded-t-none border-t-0">
|
||||||
|
<CardContent className="space-y-6 pt-7">
|
||||||
|
{/* Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
|
<span className="text-muted-foreground text-sm font-semibold">
|
||||||
|
Vereinsgröße
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-primary font-mono text-2xl font-bold">
|
||||||
|
{fmt(members)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/60 ml-1 text-sm">
|
||||||
|
Mitglieder
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={10000}
|
||||||
|
step={50}
|
||||||
|
value={members}
|
||||||
|
onChange={(e) => setMembers(+e.target.value)}
|
||||||
|
className="accent-primary w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground/40 flex justify-between text-[10px]">
|
||||||
|
<span>50</span>
|
||||||
|
<span>500</span>
|
||||||
|
<span>1.000</span>
|
||||||
|
<span>2.500</span>
|
||||||
|
<span>5.000</span>
|
||||||
|
<span>10.000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier + Period toggle */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||||
|
Ihr MYeasyCMS-Tarif
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary text-xl font-bold">
|
||||||
|
{tier.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-primary font-mono text-3xl font-bold">
|
||||||
|
{tier.price} €
|
||||||
|
</span>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
/ Monat netto
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="border-border flex overflow-hidden rounded-lg border">
|
||||||
|
{(['month', 'year'] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-semibold transition-colors',
|
||||||
|
period === p
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p === 'month' ? 'Monatlich' : 'Jährlich'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-3 text-sm font-semibold">
|
||||||
|
Preisvergleich bei {fmt(members)} Mitgliedern (
|
||||||
|
{period === 'year' ? 'pro Jahr' : 'pro Monat'})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{compPrices.map((c) => (
|
||||||
|
<PriceBar
|
||||||
|
key={c.id}
|
||||||
|
label={c.name}
|
||||||
|
note={c.note}
|
||||||
|
value={c.p ?? 0}
|
||||||
|
maxValue={maxBar}
|
||||||
|
color={c.color}
|
||||||
|
available={c.p !== null}
|
||||||
|
maxMembers={c.maxMembers}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* MYeasyCMS bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
|
<span className="text-primary font-bold">
|
||||||
|
MYeasyCMS {tier.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-primary font-mono font-bold">
|
||||||
|
{fmt(tier.price * mult)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted h-7 overflow-hidden rounded-md">
|
||||||
|
<div
|
||||||
|
className="from-primary/80 to-primary h-full rounded-md bg-gradient-to-r transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((tier.price / (maxBar * 1.1)) * 100, 100)}%`,
|
||||||
|
minWidth: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Savings callout */}
|
||||||
|
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
|
||||||
|
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||||
|
Ersparnis vs. {bestSaving.name.split(' ')[0]}
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
|
{fmt((bestSaving.p - tier.price) * 12)} €
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
||||||
|
günstiger)
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-muted/50 p-5 text-center">
|
||||||
|
<div className="text-muted-foreground text-[10px] font-bold tracking-wider uppercase">
|
||||||
|
Preis pro Mitglied
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
|
{((tier.price / members) * 100).toFixed(1)} ct
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
pro Mitglied / Monat
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SEWOBE Verband note */}
|
||||||
|
{members >= 1000 && (
|
||||||
|
<div className="bg-destructive/5 border-destructive/10 text-destructive rounded-xl border p-4 text-sm">
|
||||||
|
<strong>Hinweis zu SEWOBE:</strong> Ab Verbandsebene benötigt
|
||||||
|
SEWOBE den separaten <strong>VerbandsMANAGER</strong> — ein
|
||||||
|
eigenes Produkt mit eigener Preisliste (deutlich teurer als der
|
||||||
|
VereinsMANAGER). MYeasyCMS enthält das Verbandsmodul mit
|
||||||
|
Mehrebenen-Hierarchie bereits im Verband-Tarif.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature comparison table */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||||
|
Funktionsvergleich
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[30%]">Funktion</TableHead>
|
||||||
|
<TableHead className="bg-primary/5 text-primary text-center font-bold">
|
||||||
|
MYeasyCMS
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center">SEWOBE</TableHead>
|
||||||
|
<TableHead className="text-center">easyVerein</TableHead>
|
||||||
|
<TableHead className="text-center">ClubDesk</TableHead>
|
||||||
|
<TableHead className="text-center">WISO</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{USP_FEATURES.map((f, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="font-medium">{f.label}</TableCell>
|
||||||
|
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
|
||||||
|
(col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col}
|
||||||
|
className={cn(
|
||||||
|
'text-center',
|
||||||
|
col === 'mcms' && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FeatureCell value={f[col]} />
|
||||||
|
</TableCell>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── CTA footer ── */}
|
||||||
|
<div className="bg-primary flex flex-wrap items-center justify-between gap-4 rounded-b-2xl px-8 py-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-primary-foreground text-sm font-semibold">
|
||||||
|
{members >= 1000
|
||||||
|
? `${Math.round((1 - tier.price / (compPrices[0]?.p ?? tier.price)) * 100)}% günstiger als SEWOBE — mit dem Verbandsmodul inklusive.`
|
||||||
|
: 'Alle Funktionen inklusive. Keine versteckten Kosten.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-primary-foreground/40 text-xs">
|
||||||
|
14 Tage kostenlos testen. Persönliche Einrichtung inklusive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" size="lg" asChild>
|
||||||
|
<Link href="/auth/sign-up">
|
||||||
|
Kostenlos testen
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
|||||||
import billingConfig from '~/config/billing.config';
|
import billingConfig from '~/config/billing.config';
|
||||||
import pathsConfig from '~/config/paths.config';
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
import { PricingCalculator } from './_components/pricing-calculator';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const t = await getTranslations('marketing');
|
const t = await getTranslations('marketing');
|
||||||
|
|
||||||
@@ -23,12 +25,18 @@ async function PricingPage() {
|
|||||||
const t = await getTranslations('marketing');
|
const t = await getTranslations('marketing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex flex-col space-y-8'}>
|
<div className={'flex flex-col space-y-16'}>
|
||||||
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
|
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
|
||||||
|
|
||||||
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
{/* Pricing tiers */}
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
<PricingTable paths={paths} config={billingConfig} />
|
<PricingTable paths={paths} config={billingConfig} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Price comparison calculator */}
|
||||||
|
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
||||||
|
<PricingCalculator />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,224 @@
|
|||||||
export default async function AdminAuditPage() {
|
import { getTranslations } from 'next-intl/server';
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Protokoll</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Mandantenübergreifendes Änderungsprotokoll
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border p-6">
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
<p className="text-sm text-muted-foreground">
|
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||||
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren)
|
import { formatDateTime } from '@kit/shared/dates';
|
||||||
über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer,
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
Tabelle und Aktion.
|
import { Badge } from '@kit/ui/badge';
|
||||||
</p>
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
action?: string;
|
||||||
|
table?: string;
|
||||||
|
page?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminAuditPageProps {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
insert: 'Erstellen',
|
||||||
|
update: 'Ändern',
|
||||||
|
delete: 'Löschen',
|
||||||
|
lock: 'Sperren',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<
|
||||||
|
string,
|
||||||
|
'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
|
> = {
|
||||||
|
insert: 'default',
|
||||||
|
update: 'secondary',
|
||||||
|
delete: 'destructive',
|
||||||
|
lock: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function AuditPage(props: AdminAuditPageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const client = getSupabaseServerAdminClient();
|
||||||
|
const api = createModuleBuilderApi(client);
|
||||||
|
const t = await getTranslations('cms.audit');
|
||||||
|
|
||||||
|
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
||||||
|
|
||||||
|
const result = await api.audit.query({
|
||||||
|
action: searchParams.action || undefined,
|
||||||
|
tableName: searchParams.table || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(result.total / result.pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageBody>
|
||||||
|
<PageHeader title={t('title')} description={t('description')} />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<AuditFilters
|
||||||
|
currentAction={searchParams.action}
|
||||||
|
currentTable={searchParams.table}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results table */}
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('timestamp')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('action')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('table')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Datensatz-ID
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Benutzer-ID
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="text-muted-foreground p-8 text-center"
|
||||||
|
>
|
||||||
|
Keine Einträge gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
result.data.map((entry) => (
|
||||||
|
<tr key={entry.id} className="border-b">
|
||||||
|
<td className="p-3 text-xs">
|
||||||
|
{formatDateTime(entry.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
ACTION_COLORS[entry.action as string] ?? 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[entry.action as string] ??
|
||||||
|
String(entry.action)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.table_name)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.record_id).slice(0, 8)}...
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.user_id).slice(0, 8)}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Seite {page} von {totalPages} ({result.total} Einträge)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{page > 1 && (
|
||||||
|
<PaginationLink
|
||||||
|
page={page - 1}
|
||||||
|
action={searchParams.action}
|
||||||
|
table={searchParams.table}
|
||||||
|
label={t('paginationPrevious')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page < totalPages && (
|
||||||
|
<PaginationLink
|
||||||
|
page={page + 1}
|
||||||
|
action={searchParams.action}
|
||||||
|
table={searchParams.table}
|
||||||
|
label={t('paginationNext')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditFilters({
|
||||||
|
currentAction,
|
||||||
|
currentTable,
|
||||||
|
}: {
|
||||||
|
currentAction?: string;
|
||||||
|
currentTable?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<form className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
name="action"
|
||||||
|
defaultValue={currentAction ?? ''}
|
||||||
|
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
<option value="insert">Erstellen</option>
|
||||||
|
<option value="update">Ändern</option>
|
||||||
|
<option value="delete">Löschen</option>
|
||||||
|
<option value="lock">Sperren</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="table"
|
||||||
|
type="text"
|
||||||
|
placeholder="Tabelle filtern..."
|
||||||
|
defaultValue={currentTable ?? ''}
|
||||||
|
className="border-input bg-background flex h-9 w-48 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
page,
|
||||||
|
action,
|
||||||
|
table,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
action?: string;
|
||||||
|
table?: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(page));
|
||||||
|
if (action) params.set('action', action);
|
||||||
|
if (table) params.set('table', table);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={`?${params.toString()}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminGuard(AuditPage);
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ export default async function AdminGdprPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border p-6">
|
<div className="rounded-lg border p-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten
|
Mandantenübergreifende Übersicht aller registrierten
|
||||||
gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien,
|
Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Umfasst Zweck,
|
||||||
Aufbewahrungsfristen und technisch-organisatorische Maßnahmen.
|
Rechtsgrundlage, Datenkategorien, Aufbewahrungsfristen und
|
||||||
|
technisch-organisatorische Maßnahmen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,23 +8,27 @@ export default async function AdminMigrationPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border p-6 space-y-4">
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
<h2 className="text-lg font-semibold">Migrationsschritte</h2>
|
<h2 className="text-lg font-semibold">Migrationsschritte</h2>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
<ol className="list-inside list-decimal space-y-2 text-sm">
|
||||||
<li>MySQL-Verbindung konfigurieren</li>
|
<li>MySQL-Verbindung konfigurieren</li>
|
||||||
<li>Mandanten (user_profile → team accounts) zuordnen</li>
|
<li>Mandanten (user_profile → team accounts) zuordnen</li>
|
||||||
<li>Benutzer (cms_user → auth.users) migrieren</li>
|
<li>Benutzer (cms_user → auth.users) migrieren</li>
|
||||||
<li>Module (m_module/m_modulfeld → modules/module_fields) übertragen</li>
|
<li>
|
||||||
|
Module (m_module/m_modulfeld → modules/module_fields) übertragen
|
||||||
|
</li>
|
||||||
<li>Mitglieder (ve_mitglieder → members) importieren</li>
|
<li>Mitglieder (ve_mitglieder → members) importieren</li>
|
||||||
<li>Kurse (ve_kurse → courses) importieren</li>
|
<li>Kurse (ve_kurse → courses) importieren</li>
|
||||||
<li>Dateien (cms_files → Supabase Storage) hochladen</li>
|
<li>Dateien (cms_files → Supabase Storage) hochladen</li>
|
||||||
<li>Daten verifizieren und bereinigen</li>
|
<li>Daten verifizieren und bereinigen</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 p-4">
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
<strong>Hinweis:</strong> Die Migration erfordert eine MySQL-Verbindung zum Legacy-System.
|
<strong>Hinweis:</strong> Die Migration erfordert eine
|
||||||
Stellen Sie sicher, dass <code>mysql2</code> installiert ist und die Verbindungsdaten korrekt konfiguriert sind.
|
MySQL-Verbindung zum Legacy-System. Stellen Sie sicher, dass{' '}
|
||||||
|
<code>mysql2</code> installiert ist und die Verbindungsdaten korrekt
|
||||||
|
konfiguriert sind.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export default async function AdminModulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border p-6">
|
<div className="rounded-lg border p-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Hier werden alle Module über alle Mandanten hinweg angezeigt.
|
Hier werden alle Module über alle Mandanten hinweg angezeigt.
|
||||||
Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen.
|
Ermöglicht die zentrale Verwaltung von Modulvorlagen und
|
||||||
|
-konfigurationen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { SiteRenderer } from '@kit/site-builder/components';
|
import { SiteRenderer } from '@kit/site-builder/components';
|
||||||
import type { SiteData } from '@kit/site-builder/context';
|
import type { SiteData } from '@kit/site-builder/context';
|
||||||
|
|
||||||
interface Props { params: Promise<{ slug: string; page: string[] }> }
|
interface Props {
|
||||||
|
params: Promise<{ slug: string; page: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClubSubPage({ params }: Props) {
|
export default async function ClubSubPage({ params }: Props) {
|
||||||
const { slug, page: pagePath } = await params;
|
const { slug, page: pagePath } = await params;
|
||||||
@@ -14,36 +18,73 @@ export default async function ClubSubPage({ params }: Props) {
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
if (!account) notFound();
|
if (!account) notFound();
|
||||||
|
|
||||||
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
|
const { data: settings } = await supabase
|
||||||
|
.from('site_settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('is_public', true)
|
||||||
|
.maybeSingle();
|
||||||
if (!settings) notFound();
|
if (!settings) notFound();
|
||||||
|
|
||||||
const { data: sitePageData } = await supabase.from('site_pages').select('*')
|
const { data: sitePageData } = await supabase
|
||||||
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
|
.from('site_pages')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('slug', pageSlug)
|
||||||
|
.eq('is_published', true)
|
||||||
|
.maybeSingle();
|
||||||
if (!sitePageData) notFound();
|
if (!sitePageData) notFound();
|
||||||
|
|
||||||
// Pre-fetch CMS data for Puck components
|
// Pre-fetch CMS data for Puck components
|
||||||
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
||||||
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
|
supabase
|
||||||
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
|
.from('events')
|
||||||
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
|
.select('id, name, event_date, event_time, location, fee, status')
|
||||||
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
|
.eq('account_id', account.id)
|
||||||
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
|
.order('event_date', { ascending: true })
|
||||||
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
|
.limit(20),
|
||||||
|
supabase
|
||||||
|
.from('courses')
|
||||||
|
.select('id, name, start_date, end_date, fee, capacity, status')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.order('start_date', { ascending: true })
|
||||||
|
.limit(20),
|
||||||
|
supabase
|
||||||
|
.from('cms_posts')
|
||||||
|
.select('id, title, excerpt, cover_image, published_at, slug')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('status', 'published')
|
||||||
|
.order('published_at', { ascending: false })
|
||||||
|
.limit(20),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteData: SiteData = {
|
const siteData: SiteData = {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
events: eventsRes.data ?? [],
|
events: eventsRes.data ?? [],
|
||||||
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
|
courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
|
||||||
posts: postsRes.data ?? [],
|
posts: postsRes.data ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
<div
|
||||||
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
|
style={
|
||||||
|
{
|
||||||
|
'--primary': settings.primary_color,
|
||||||
|
fontFamily: settings.font_family,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SiteRenderer
|
||||||
|
data={(sitePageData.puck_data ?? {}) as Record<string, unknown>}
|
||||||
|
siteData={siteData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Mail } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Props { params: Promise<{ slug: string }> }
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NewsletterSubscribePage({ params }: Props) {
|
export default async function NewsletterSubscribePage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug: _slug } = await params;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<Mail className="h-6 w-6 text-primary" />
|
<Mail className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Newsletter abonnieren</CardTitle>
|
<CardTitle>Newsletter abonnieren</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Bleiben Sie über Neuigkeiten informiert.
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="space-y-4">
|
<form className="space-y-4">
|
||||||
@@ -27,11 +32,19 @@ export default async function NewsletterSubscribePage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>E-Mail-Adresse *</Label>
|
<Label>E-Mail-Adresse *</Label>
|
||||||
<Input name="email" type="email" placeholder="ihre@email.de" required />
|
<Input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ihre@email.de"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">Abonnieren</Button>
|
<Button type="submit" className="w-full">
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
Abonnieren
|
||||||
Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail.
|
</Button>
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
Sie können sich jederzeit abmelden. Wir senden Ihnen eine
|
||||||
|
Bestätigungs-E-Mail.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { MailX } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
|
import { MailX } from 'lucide-react';
|
||||||
|
|
||||||
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewsletterUnsubscribePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { token } = await searchParams;
|
const { token } = await searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||||
<Card className="w-full max-w-md text-center">
|
<Card className="w-full max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
<div className="bg-destructive/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<MailX className="h-6 w-6 text-destructive" />
|
<MailX className="text-destructive h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Newsletter abbestellen</CardTitle>
|
<CardTitle>Newsletter abbestellen</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{token ? (
|
{token ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">Möchten Sie den Newsletter wirklich abbestellen?</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
<Button variant="destructive" className="w-full">Abbestellen bestätigen</Button>
|
Möchten Sie den Newsletter wirklich abbestellen?
|
||||||
|
</p>
|
||||||
|
<Button variant="destructive" className="w-full">
|
||||||
|
Abbestellen bestätigen
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der
|
||||||
|
Newsletter-E-Mail.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Link href={`/club/${slug}`}>
|
<Link href={`/club/${slug}`}>
|
||||||
<Button variant="outline" size="sm">← Zurück zur Website</Button>
|
<Button variant="outline" size="sm">
|
||||||
|
← Zurück zur Website
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { SiteRenderer } from '@kit/site-builder/components';
|
import { SiteRenderer } from '@kit/site-builder/components';
|
||||||
import type { SiteData } from '@kit/site-builder/context';
|
import type { SiteData } from '@kit/site-builder/context';
|
||||||
|
|
||||||
interface Props { params: Promise<{ slug: string }> }
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClubHomePage({ params }: Props) {
|
export default async function ClubHomePage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
@@ -13,36 +17,74 @@ export default async function ClubHomePage({ params }: Props) {
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
|
.from('accounts')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
if (!account) notFound();
|
if (!account) notFound();
|
||||||
|
|
||||||
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
|
const { data: settings } = await supabase
|
||||||
|
.from('site_settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('is_public', true)
|
||||||
|
.maybeSingle();
|
||||||
if (!settings) notFound();
|
if (!settings) notFound();
|
||||||
|
|
||||||
const { data: page } = await supabase.from('site_pages').select('*')
|
const { data: page } = await supabase
|
||||||
.eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
|
.from('site_pages')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('is_homepage', true)
|
||||||
|
.eq('is_published', true)
|
||||||
|
.maybeSingle();
|
||||||
if (!page) notFound();
|
if (!page) notFound();
|
||||||
|
|
||||||
// Pre-fetch CMS data for Puck components
|
// Pre-fetch CMS data for Puck components
|
||||||
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
const [eventsRes, coursesRes, postsRes] = await Promise.all([
|
||||||
supabase.from('events').select('id, name, event_date, event_time, location, fee, status')
|
supabase
|
||||||
.eq('account_id', account.id).order('event_date', { ascending: true }).limit(20),
|
.from('events')
|
||||||
supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status')
|
.select('id, name, event_date, event_time, location, fee, status')
|
||||||
.eq('account_id', account.id).order('start_date', { ascending: true }).limit(20),
|
.eq('account_id', account.id)
|
||||||
supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug')
|
.order('event_date', { ascending: true })
|
||||||
.eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20),
|
.limit(20),
|
||||||
|
supabase
|
||||||
|
.from('courses')
|
||||||
|
.select('id, name, start_date, end_date, fee, capacity, status')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.order('start_date', { ascending: true })
|
||||||
|
.limit(20),
|
||||||
|
supabase
|
||||||
|
.from('cms_posts')
|
||||||
|
.select('id, title, excerpt, cover_image, published_at, slug')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('status', 'published')
|
||||||
|
.order('published_at', { ascending: false })
|
||||||
|
.limit(20),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteData: SiteData = {
|
const siteData: SiteData = {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
events: eventsRes.data ?? [],
|
events: eventsRes.data ?? [],
|
||||||
courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })),
|
courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })),
|
||||||
posts: postsRes.data ?? [],
|
posts: postsRes.data ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
<div
|
||||||
<SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
|
style={
|
||||||
|
{
|
||||||
|
'--primary': settings.primary_color,
|
||||||
|
'--secondary': settings.secondary_color,
|
||||||
|
fontFamily: settings.font_family,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SiteRenderer
|
||||||
|
data={(page.puck_data ?? {}) as Record<string, unknown>}
|
||||||
|
siteData={siteData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,134 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Badge } from '@kit/ui/badge';
|
|
||||||
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PortalDocumentsPage({ params }: Props) {
|
export default async function PortalDocumentsPage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const t = await getTranslations('portal');
|
||||||
|
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
process.env.SUPABASE_SERVICE_ROLE_KEY ||
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
.from('accounts')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
|
if (!account)
|
||||||
|
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
|
||||||
|
|
||||||
// Demo documents (in production: query invoices + cms_files for this member)
|
// Demo documents (in production: query invoices + cms_files for this member)
|
||||||
const documents = [
|
const documents = [
|
||||||
{ id: '1', title: 'Mitgliedsbeitrag 2026', type: 'Rechnung', date: '2026-01-15', status: 'paid' },
|
{
|
||||||
{ id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' },
|
id: '1',
|
||||||
{ id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' },
|
title: 'Mitgliedsbeitrag 2026',
|
||||||
|
type: t('documents.typeInvoice'),
|
||||||
|
date: '2026-01-15',
|
||||||
|
status: 'paid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Mitgliedsbeitrag 2025',
|
||||||
|
type: t('documents.typeInvoice'),
|
||||||
|
date: '2025-01-10',
|
||||||
|
status: 'paid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Beitrittserklärung',
|
||||||
|
type: t('documents.typeDocument'),
|
||||||
|
date: '2020-01-15',
|
||||||
|
status: 'signed',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid': return <Badge variant="default">Bezahlt</Badge>;
|
case 'paid':
|
||||||
case 'open': return <Badge variant="secondary">Offen</Badge>;
|
return <Badge variant="default">{t('documents.statusPaid')}</Badge>;
|
||||||
case 'signed': return <Badge variant="outline">Unterschrieben</Badge>;
|
case 'open':
|
||||||
default: return <Badge variant="secondary">{status}</Badge>;
|
return <Badge variant="secondary">{t('documents.statusOpen')}</Badge>;
|
||||||
|
case 'signed':
|
||||||
|
return <Badge variant="outline">{t('documents.statusSigned')}</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
switch (type) {
|
if (type === t('documents.typeInvoice')) {
|
||||||
case 'Rechnung': return <Receipt className="h-5 w-5 text-primary" />;
|
return <Receipt className="text-primary h-5 w-5" />;
|
||||||
case 'Dokument': return <FileCheck className="h-5 w-5 text-primary" />;
|
|
||||||
default: return <FileText className="h-5 w-5 text-primary" />;
|
|
||||||
}
|
}
|
||||||
|
if (type === t('documents.typeDocument')) {
|
||||||
|
return <FileCheck className="text-primary h-5 w-5" />;
|
||||||
|
}
|
||||||
|
return <FileText className="text-primary h-5 w-5" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30">
|
<div className="bg-muted/30 min-h-screen">
|
||||||
<header className="border-b bg-background px-6 py-4">
|
<header className="bg-background border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
<h1 className="text-lg font-bold">Meine Dokumente</h1>
|
<h1 className="text-lg font-bold">{t('documents.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/club/${slug}/portal`}>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Button variant="ghost" size="sm">← Zurück zum Portal</Button>
|
<Link href={`/club/${slug}/portal`}>{t('home.backToPortal')}</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto py-8 px-6">
|
<main className="mx-auto max-w-3xl px-6 py-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Verfügbare Dokumente</CardTitle>
|
<CardTitle>{t('documents.available')}</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">{String(account.name)} — Dokumente und Rechnungen</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{String(account.name)} — {t('documents.subtitle')}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
<FileText className="mx-auto h-10 w-10 mb-3" />
|
<FileText className="mx-auto mb-3 h-10 w-10" />
|
||||||
<p>Keine Dokumente vorhanden</p>
|
<p>{t('documents.empty')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => (
|
||||||
<div key={doc.id} className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/30 transition-colors">
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="hover:bg-muted/30 flex items-center justify-between rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getIcon(doc.type)}
|
{getIcon(doc.type)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{doc.title}</p>
|
<p className="text-sm font-medium">{doc.title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{doc.type} — {new Date(doc.date).toLocaleDateString('de-DE')}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{doc.type} — {formatDate(doc.date)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getStatusBadge(doc.status)}
|
{getStatusBadge(doc.status)}
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
<Download className="h-3 w-3 mr-1" />
|
<Download className="mr-1 h-3 w-3" />
|
||||||
PDF
|
{t('documents.downloadPdf')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
searchParams: Promise<{ token?: string }>;
|
searchParams: Promise<{ token?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PortalInvitePage({ params, searchParams }: Props) {
|
export default async function PortalInvitePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { token } = await searchParams;
|
const { token } = await searchParams;
|
||||||
|
const t = await getTranslations('portal');
|
||||||
|
|
||||||
if (!token) notFound();
|
if (!token) notFound();
|
||||||
|
|
||||||
@@ -24,29 +33,33 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Resolve account
|
// Resolve account
|
||||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
|
.from('accounts')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
if (!account) notFound();
|
if (!account) notFound();
|
||||||
|
|
||||||
// Look up invitation
|
// Look up invitation
|
||||||
const { data: invitation } = await supabase.from('member_portal_invitations')
|
const { data: invitation } = await supabase
|
||||||
|
.from('member_portal_invitations')
|
||||||
.select('id, email, status, expires_at, member_id')
|
.select('id, email, status, expires_at, member_id')
|
||||||
.eq('invite_token', token)
|
.eq('invite_token', token)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!invitation || invitation.status !== 'pending') {
|
if (!invitation || invitation.status !== 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||||
<Card className="max-w-md text-center">
|
<Card className="max-w-md text-center">
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
|
||||||
<h2 className="text-lg font-bold">Einladung ungültig</h2>
|
<h2 className="text-lg font-bold">{t('invite.invalidTitle')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
|
{t('invite.invalidDesc')}
|
||||||
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/club/${slug}`}>
|
<Button variant="outline" className="mt-4" asChild>
|
||||||
<Button variant="outline" className="mt-4">← Zur Website</Button>
|
<Link href={`/club/${slug}`}>{t('invite.backToWebsite')}</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,14 +69,15 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
|||||||
const expired = new Date(invitation.expires_at) < new Date();
|
const expired = new Date(invitation.expires_at) < new Date();
|
||||||
if (expired) {
|
if (expired) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||||
<Card className="max-w-md text-center">
|
<Card className="max-w-md text-center">
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" />
|
<Shield className="mx-auto mb-4 h-10 w-10 text-amber-500" />
|
||||||
<h2 className="text-lg font-bold">Einladung abgelaufen</h2>
|
<h2 className="text-lg font-bold">{t('invite.expiredTitle')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
|
{t('invite.expiredDesc', {
|
||||||
Bitte fordern Sie eine neue Einladung an.
|
date: formatDate(invitation.expires_at),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -72,51 +86,79 @@ export default async function PortalInvitePage({ params, searchParams }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center p-6">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<UserPlus className="h-6 w-6 text-primary" />
|
<UserPlus className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
|
<CardTitle>{t('invite.title')}</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{String(account.name)}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6">
|
<div className="bg-primary/5 border-primary/20 mb-6 rounded-md border p-4">
|
||||||
<p className="text-sm">
|
<p className="text-sm">{t('invite.invitedDesc')}</p>
|
||||||
Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen.
|
|
||||||
Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-4" action={`/api/club/accept-invite`} method="POST">
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
action={`/api/club/accept-invite`}
|
||||||
|
method="POST"
|
||||||
|
>
|
||||||
<input type="hidden" name="token" value={token} />
|
<input type="hidden" name="token" value={token} />
|
||||||
<input type="hidden" name="slug" value={slug} />
|
<input type="hidden" name="slug" value={slug} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>E-Mail-Adresse</Label>
|
<Label>{t('invite.emailLabel')}</Label>
|
||||||
<Input type="email" value={invitation.email} readOnly className="bg-muted" />
|
<Input
|
||||||
<p className="text-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p>
|
type="email"
|
||||||
|
value={invitation.email}
|
||||||
|
readOnly
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('invite.emailNote')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Passwort festlegen *</Label>
|
<Label>{t('invite.passwordLabel')}</Label>
|
||||||
<Input type="password" name="password" placeholder="Mindestens 8 Zeichen" required minLength={8} />
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={t('invite.passwordPlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Passwort wiederholen *</Label>
|
<Label>{t('invite.passwordConfirmLabel')}</Label>
|
||||||
<Input type="password" name="passwordConfirm" placeholder="Passwort bestätigen" required minLength={8} />
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="passwordConfirm"
|
||||||
|
placeholder={t('invite.passwordConfirmPlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
Konto erstellen & Einladung annehmen
|
{t('invite.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-center text-muted-foreground">
|
<p className="text-muted-foreground mt-4 text-center text-xs">
|
||||||
Bereits ein Konto? <Link href={`/club/${slug}/portal`} className="text-primary underline">Anmelden</Link>
|
{t('invite.hasAccount')}{' '}
|
||||||
|
<Link
|
||||||
|
href={`/club/${slug}/portal`}
|
||||||
|
className="text-primary underline"
|
||||||
|
>
|
||||||
|
{t('invite.login')}
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { PortalLoginForm } from '@kit/site-builder/components';
|
import { PortalLoginForm } from '@kit/site-builder/components';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -12,21 +15,30 @@ interface Props {
|
|||||||
|
|
||||||
export default async function MemberPortalPage({ params }: Props) {
|
export default async function MemberPortalPage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const t = await getTranslations('portal');
|
||||||
|
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
.from('accounts')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
|
if (!account)
|
||||||
|
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
|
||||||
|
|
||||||
// Check if user is already logged in
|
// Check if user is already logged in
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Check if this user is a member of this club
|
// Check if this user is a member of this club
|
||||||
const { data: member } = await supabase.from('members')
|
const { data: member } = await supabase
|
||||||
|
.from('members')
|
||||||
.select('id, first_name, last_name, status')
|
.select('id, first_name, last_name, status')
|
||||||
.eq('account_id', account.id)
|
.eq('account_id', account.id)
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
@@ -35,45 +47,59 @@ export default async function MemberPortalPage({ params }: Props) {
|
|||||||
if (member) {
|
if (member) {
|
||||||
// Logged in member — show portal dashboard
|
// Logged in member — show portal dashboard
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30">
|
<div className="bg-muted/30 min-h-screen">
|
||||||
<header className="border-b bg-background px-6 py-4">
|
<header className="bg-background border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
<h1 className="text-lg font-bold">Mitgliederbereich — {String(account.name)}</h1>
|
<h1 className="text-lg font-bold">
|
||||||
|
{t('home.membersArea')} — {String(account.name)}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm">← Website</Button></Link>
|
{String(member.first_name)} {String(member.last_name)}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/club/${slug}`}>{t('home.backToWebsite')}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-4xl mx-auto py-12 px-6">
|
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||||
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
|
<h2 className="mb-6 text-2xl font-bold">
|
||||||
|
{t('home.welcomeUser', { name: String(member.first_name) })}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<Link href={`/club/${slug}/portal/profile`}>
|
<Link href={`/club/${slug}/portal/profile`}>
|
||||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
<Card className="hover:border-primary cursor-pointer transition-colors">
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
|
<UserCircle className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||||
<h3 className="font-semibold">Mein Profil</h3>
|
<h3 className="font-semibold">{t('home.profile')}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{t('home.profileDesc')}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/club/${slug}/portal/documents`}>
|
<Link href={`/club/${slug}/portal/documents`}>
|
||||||
<Card className="hover:border-primary transition-colors cursor-pointer">
|
<Card className="hover:border-primary cursor-pointer transition-colors">
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
<FileText className="mx-auto h-10 w-10 text-primary mb-3" />
|
<FileText className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||||
<h3 className="font-semibold">Dokumente</h3>
|
<h3 className="font-semibold">{t('home.documents')}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{t('home.documentsDesc')}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
|
<CreditCard className="text-primary mx-auto mb-3 h-10 w-10" />
|
||||||
<h3 className="font-semibold">Mitgliedsausweis</h3>
|
<h3 className="font-semibold">{t('home.memberCard')}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{t('home.memberCardDesc')}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,14 +111,16 @@ export default async function MemberPortalPage({ params }: Props) {
|
|||||||
|
|
||||||
// Not logged in or not a member — show login form
|
// Not logged in or not a member — show login form
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30">
|
<div className="bg-muted/30 min-h-screen">
|
||||||
<header className="border-b bg-background px-6 py-4">
|
<header className="bg-background border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||||
<h1 className="text-lg font-bold">Mitgliederbereich</h1>
|
<h1 className="text-lg font-bold">{t('home.membersArea')}</h1>
|
||||||
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm">← Zurück zur Website</Button></Link>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/club/${slug}`}>{t('home.backToWebsiteFull')}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-4xl mx-auto py-12 px-6">
|
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||||
<PortalLoginForm slug={slug} accountName={String(account.name)} />
|
<PortalLoginForm slug={slug} accountName={String(account.name)} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { Provider, UserIdentity } from '@supabase/supabase-js';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { Link2Off, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
const PROVIDERS: Provider[] = ['google', 'apple', 'azure', 'github'];
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
google: 'Google',
|
||||||
|
apple: 'Apple',
|
||||||
|
azure: 'Microsoft',
|
||||||
|
github: 'GitHub',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSupabaseClient() {
|
||||||
|
return createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalLinkedAccounts({ slug }: { slug: string }) {
|
||||||
|
const t = useTranslations('portal');
|
||||||
|
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadIdentities = useCallback(async () => {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (user?.identities) {
|
||||||
|
setIdentities(user.identities);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadIdentities();
|
||||||
|
}, [loadIdentities]);
|
||||||
|
|
||||||
|
const handleLink = async (provider: Provider) => {
|
||||||
|
setActionLoading(provider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const redirectTo = `${window.location.origin}/club/${slug}/portal/profile`;
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.linkIdentity({
|
||||||
|
provider,
|
||||||
|
options: { redirectTo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(`Verknüpfung fehlgeschlagen: ${error.message}`);
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async (identity: UserIdentity) => {
|
||||||
|
if (identities.length <= 1) {
|
||||||
|
toast.error('Sie benötigen mindestens eine Anmeldemethode.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(identity.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const { error } = await supabase.auth.unlinkIdentity(identity);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(`Trennung fehlgeschlagen: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`${PROVIDER_LABELS[identity.provider] ?? identity.provider} wurde getrennt.`,
|
||||||
|
);
|
||||||
|
await loadIdentities();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedProviders = identities
|
||||||
|
.filter((i) => i.provider !== 'email')
|
||||||
|
.map((i) => i.provider);
|
||||||
|
|
||||||
|
const availableProviders = PROVIDERS.filter(
|
||||||
|
(p) => !connectedProviders.includes(p),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Connected accounts */}
|
||||||
|
{identities.filter((i) => i.provider !== 'email').length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Verknüpfte Konten
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{identities
|
||||||
|
.filter((i) => i.provider !== 'email')
|
||||||
|
.map((identity) => (
|
||||||
|
<div
|
||||||
|
key={identity.id}
|
||||||
|
className="bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center">
|
||||||
|
<OauthProviderLogoImage providerId={identity.provider} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium capitalize">
|
||||||
|
{PROVIDER_LABELS[identity.provider] ?? identity.provider}
|
||||||
|
</p>
|
||||||
|
{identity.identity_data?.email && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{identity.identity_data.email as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{identities.length > 1 && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionLoading === identity.id}
|
||||||
|
>
|
||||||
|
{actionLoading === identity.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Link2Off className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t('linkedAccounts.title')}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('linkedAccounts.disconnectDesc')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t('linkedAccounts.cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleUnlink(identity)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t('linkedAccounts.disconnect')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available providers to link */}
|
||||||
|
{availableProviders.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
{t('linkedAccounts.connect')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableProviders.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={actionLoading === provider}
|
||||||
|
onClick={() => handleLink(provider)}
|
||||||
|
>
|
||||||
|
{actionLoading === provider ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<OauthProviderLogoImage providerId={provider} />
|
||||||
|
)}
|
||||||
|
{PROVIDER_LABELS[provider] ?? provider}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info text when email-only */}
|
||||||
|
{identities.length <= 1 && availableProviders.length > 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Verknüpfen Sie ein Konto, um sich zukünftig schneller und ohne
|
||||||
|
Passwort anmelden zu können.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { UserCircle, Mail, MapPin, Shield, Link2 } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import { PortalLinkedAccounts } from './_components/portal-linked-accounts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -13,21 +20,30 @@ interface Props {
|
|||||||
|
|
||||||
export default async function PortalProfilePage({ params }: Props) {
|
export default async function PortalProfilePage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const t = await getTranslations('portal');
|
||||||
|
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
const { data: account } = await supabase
|
||||||
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
.from('accounts')
|
||||||
|
.select('id, name')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single();
|
||||||
|
if (!account)
|
||||||
|
return <div className="p-8 text-center">{t('home.orgNotFound')}</div>;
|
||||||
|
|
||||||
// Get current user
|
// Get current user
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
if (!user) redirect(`/club/${slug}/portal`);
|
if (!user) redirect(`/club/${slug}/portal`);
|
||||||
|
|
||||||
// Find member linked to this user
|
// Find member linked to this user
|
||||||
const { data: member } = await supabase.from('members')
|
const { data: member } = await supabase
|
||||||
|
.from('members')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('account_id', account.id)
|
.eq('account_id', account.id)
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
@@ -35,18 +51,17 @@ export default async function PortalProfilePage({ params }: Props) {
|
|||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
|
<div className="bg-muted/30 flex min-h-screen items-center justify-center">
|
||||||
<Card className="max-w-md">
|
<Card className="max-w-md">
|
||||||
<CardContent className="p-8 text-center">
|
<CardContent className="p-8 text-center">
|
||||||
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
<Shield className="text-destructive mx-auto mb-4 h-10 w-10" />
|
||||||
<h2 className="text-lg font-bold">Kein Mitglied</h2>
|
<h2 className="text-lg font-bold">{t('profile.noMemberTitle')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
|
{t('profile.noMemberDesc')}
|
||||||
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/club/${slug}/portal`}>
|
<Button variant="outline" className="mt-4" asChild>
|
||||||
<Button variant="outline" className="mt-4">← Zurück</Button>
|
<Link href={`/club/${slug}/portal`}>{t('profile.back')}</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,28 +71,35 @@ export default async function PortalProfilePage({ params }: Props) {
|
|||||||
const m = member;
|
const m = member;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30">
|
<div className="bg-muted/30 min-h-screen">
|
||||||
<header className="border-b bg-background px-6 py-4">
|
<header className="bg-background border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="text-primary h-5 w-5" />
|
||||||
<h1 className="text-lg font-bold">Mein Profil</h1>
|
<h1 className="text-lg font-bold">{t('profile.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm">← Zurück zum Portal</Button></Link>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/club/${slug}/portal`}>{t('home.backToPortal')}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6">
|
<main className="mx-auto max-w-3xl space-y-6 px-6 py-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className="bg-primary/10 text-primary flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
<UserCircle className="h-8 w-8" />
|
<UserCircle className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
|
<h2 className="text-xl font-bold">
|
||||||
<p className="text-sm text-muted-foreground">
|
{String(m.first_name)} {String(m.last_name)}
|
||||||
Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t('profile.memberSince', {
|
||||||
|
number: String(m.member_number ?? '—'),
|
||||||
|
date: formatDate(m.entry_date),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,37 +107,111 @@ export default async function PortalProfilePage({ params }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t('profile.contactData')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2"><Label>Vorname</Label><Input defaultValue={String(m.first_name)} readOnly /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div>
|
<Label>{t('profile.firstName')}</Label>
|
||||||
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div>
|
<Input defaultValue={String(m.first_name)} readOnly />
|
||||||
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div>
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.lastName')}</Label>
|
||||||
|
<Input defaultValue={String(m.last_name)} readOnly />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.email')}</Label>
|
||||||
|
<Input defaultValue={String(m.email ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.phone')}</Label>
|
||||||
|
<Input defaultValue={String(m.phone ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.mobile')}</Label>
|
||||||
|
<Input defaultValue={String(m.mobile ?? '')} />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
{t('profile.address')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2"><Label>Straße</Label><Input defaultValue={String(m.street ?? '')} /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Hausnummer</Label><Input defaultValue={String(m.house_number ?? '')} /></div>
|
<Label>{t('profile.street')}</Label>
|
||||||
<div className="space-y-2"><Label>PLZ</Label><Input defaultValue={String(m.postal_code ?? '')} /></div>
|
<Input defaultValue={String(m.street ?? '')} />
|
||||||
<div className="space-y-2"><Label>Ort</Label><Input defaultValue={String(m.city ?? '')} /></div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.houseNumber')}</Label>
|
||||||
|
<Input defaultValue={String(m.house_number ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.postalCode')}</Label>
|
||||||
|
<Input defaultValue={String(m.postal_code ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('profile.city')}</Label>
|
||||||
|
<Input defaultValue={String(m.city ?? '')} />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
{t('profile.loginMethods')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PortalLinkedAccounts slug={slug} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
{t('profile.privacy')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter },
|
{
|
||||||
{ key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet },
|
key: 'gdpr_newsletter',
|
||||||
{ key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print },
|
label: t('profile.gdprNewsletter'),
|
||||||
{ key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info },
|
value: m.gdpr_newsletter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gdpr_internet',
|
||||||
|
label: t('profile.gdprInternet'),
|
||||||
|
value: m.gdpr_internet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gdpr_print',
|
||||||
|
label: t('profile.gdprPrint'),
|
||||||
|
value: m.gdpr_print,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gdpr_birthday_info',
|
||||||
|
label: t('profile.gdprBirthday'),
|
||||||
|
value: m.gdpr_birthday_info,
|
||||||
|
},
|
||||||
].map(({ key, label, value }) => (
|
].map(({ key, label, value }) => (
|
||||||
<label key={key} className="flex items-center gap-3 text-sm">
|
<label key={key} className="flex items-center gap-3 text-sm">
|
||||||
<input type="checkbox" defaultChecked={Boolean(value)} className="h-4 w-4 rounded border-input" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(value)}
|
||||||
|
className="border-input h-4 w-4 rounded"
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -123,7 +219,7 @@ export default async function PortalProfilePage({ params }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button>Änderungen speichern</Button>
|
<Button>{t('profile.saveChanges')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -21,37 +23,21 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import {
|
||||||
|
BOOKING_STATUS_VARIANT as STATUS_BADGE_VARIANT,
|
||||||
|
BOOKING_STATUS_LABEL_KEYS as STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; bookingId: string }>;
|
params: Promise<{ account: string; bookingId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_BADGE_VARIANT: Record<
|
|
||||||
string,
|
|
||||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
|
||||||
> = {
|
|
||||||
pending: 'secondary',
|
|
||||||
confirmed: 'default',
|
|
||||||
checked_in: 'info',
|
|
||||||
checked_out: 'outline',
|
|
||||||
cancelled: 'destructive',
|
|
||||||
no_show: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: 'Ausstehend',
|
|
||||||
confirmed: 'Bestätigt',
|
|
||||||
checked_in: 'Eingecheckt',
|
|
||||||
checked_out: 'Ausgecheckt',
|
|
||||||
cancelled: 'Storniert',
|
|
||||||
no_show: 'Nicht erschienen',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BookingDetailPage({ params }: PageProps) {
|
export default async function BookingDetailPage({ params }: PageProps) {
|
||||||
const { account, bookingId } = await params;
|
const { account, bookingId } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -61,7 +47,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Buchungsdetails">
|
<CmsPageShell account={account} title={t('detail.title')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
@@ -77,17 +63,17 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Buchung nicht gefunden">
|
<CmsPageShell account={account} title={t('detail.notFound')}>
|
||||||
<div className="flex flex-col items-center gap-4 py-12">
|
<div className="flex flex-col items-center gap-4 py-12">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Buchung mit ID "{bookingId}" wurde nicht gefunden.
|
{t('detail.notFoundDesc', { id: bookingId })}
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/home/${account}/bookings`}>
|
<Button variant="outline" asChild>
|
||||||
<Button variant="outline">
|
<Link href={`/home/${account}/bookings`}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Zurück zu Buchungen
|
{t('detail.backToBookings')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
@@ -108,44 +94,47 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
const status = String(booking.status ?? 'pending');
|
const status = String(booking.status ?? 'pending');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Buchungsdetails">
|
<CmsPageShell account={account} title={t('detail.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href={`/home/${account}/bookings`}>
|
<Button
|
||||||
<Button variant="ghost" size="icon">
|
variant="ghost"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
asChild
|
||||||
</Link>
|
aria-label={t('detail.backToBookings')}
|
||||||
|
>
|
||||||
|
<Link href={`/home/${account}/bookings`}>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
|
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
|
||||||
{STATUS_LABEL[status] ?? status}
|
{t(STATUS_LABEL_KEYS[status] ?? status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">ID: {bookingId}</p>
|
||||||
ID: {bookingId}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* Zimmer */}
|
{/* Room */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<BedDouble className="h-5 w-5" />
|
<BedDouble className="h-5 w-5" />
|
||||||
Zimmer
|
{t('detail.room')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{room ? (
|
{room ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Zimmernummer
|
{t('detail.roomNumber')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{String(room.room_number)}
|
{String(room.room_number)}
|
||||||
@@ -153,14 +142,16 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
{room.name && (
|
{room.name && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Name
|
{t('rooms.name')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">{String(room.name)}</span>
|
<span className="font-medium">{String(room.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Typ</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{t('detail.type')}
|
||||||
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{String(room.room_type ?? '—')}
|
{String(room.room_type ?? '—')}
|
||||||
</span>
|
</span>
|
||||||
@@ -168,145 +159,119 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Kein Zimmer zugewiesen
|
{t('detail.noRoom')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Gast */}
|
{/* Guest */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
Gast
|
{t('detail.guest')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{guest ? (
|
{guest ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Name</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{t('guests.name')}
|
||||||
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{String(guest.first_name)} {String(guest.last_name)}
|
{String(guest.first_name)} {String(guest.last_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{guest.email && (
|
{guest.email && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
E-Mail
|
{t('detail.email')}
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{String(guest.email)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-medium">{String(guest.email)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{guest.phone && (
|
{guest.phone && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Telefon
|
{t('detail.phone')}
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{String(guest.phone)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-medium">{String(guest.phone)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Kein Gast zugewiesen
|
{t('detail.noGuest')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Aufenthalt */}
|
{/* Stay */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CalendarDays className="h-5 w-5" />
|
<CalendarDays className="h-5 w-5" />
|
||||||
Aufenthalt
|
{t('detail.stay')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Check-in
|
{t('list.checkIn')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{booking.check_in
|
{formatDate(booking.check_in)}
|
||||||
? new Date(String(booking.check_in)).toLocaleDateString(
|
|
||||||
'de-DE',
|
|
||||||
{
|
|
||||||
weekday: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Check-out
|
{t('list.checkOut')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{booking.check_out
|
{formatDate(booking.check_out)}
|
||||||
? new Date(String(booking.check_out)).toLocaleDateString(
|
|
||||||
'de-DE',
|
|
||||||
{
|
|
||||||
weekday: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Erwachsene
|
{t('detail.adults')}
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{booking.adults ?? '—'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-medium">{booking.adults ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Kinder
|
{t('detail.children')}
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{booking.children ?? 0}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-medium">{booking.children ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Betrag */}
|
{/* Amount */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Betrag</CardTitle>
|
<CardTitle>{t('detail.amount')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Gesamtpreis
|
{t('detail.totalPrice')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-bold">
|
<span className="text-2xl font-bold">
|
||||||
{booking.total_price != null
|
{booking.total_price != null
|
||||||
? `${Number(booking.total_price).toFixed(2)} €`
|
? formatCurrencyAmount(booking.total_price as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{booking.notes && (
|
{booking.notes && (
|
||||||
<div className="border-t pt-2">
|
<div className="border-t pt-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
Notizen
|
{t('detail.notes')}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1 text-sm">{String(booking.notes)}</p>
|
<p className="mt-1 text-sm">{String(booking.notes)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,24 +284,22 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
{/* Status Workflow */}
|
{/* Status Workflow */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Aktionen</CardTitle>
|
<CardTitle>{t('detail.actions')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('detail.changeStatus')}</CardDescription>
|
||||||
Status der Buchung ändern
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{(status === 'pending' || status === 'confirmed') && (
|
{(status === 'pending' || status === 'confirmed') && (
|
||||||
<Button variant="default">
|
<Button variant="default">
|
||||||
<LogIn className="mr-2 h-4 w-4" />
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
Einchecken
|
{t('detail.checkIn')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'checked_in' && (
|
{status === 'checked_in' && (
|
||||||
<Button variant="default">
|
<Button variant="default">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Auschecken
|
{t('detail.checkOut')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -345,15 +308,18 @@ export default async function BookingDetailPage({ params }: PageProps) {
|
|||||||
status !== 'no_show' && (
|
status !== 'no_show' && (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
Stornieren
|
{t('detail.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'cancelled' || status === 'checked_out' ? (
|
{status === 'cancelled' || status === 'checked_out' ? (
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<p className="text-muted-foreground py-2 text-sm">
|
||||||
Diese Buchung ist{' '}
|
{t('detail.noMoreActions', {
|
||||||
{status === 'cancelled' ? 'storniert' : 'abgeschlossen'} — keine
|
statusLabel:
|
||||||
weiteren Aktionen verfügbar.
|
status === 'cancelled'
|
||||||
|
? t('detail.cancelledStatus')
|
||||||
|
: t('detail.completedStatus'),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -43,12 +43,17 @@ function getFirstWeekday(year: number, month: number): number {
|
|||||||
return day === 0 ? 6 : day - 1;
|
return day === 0 ? 6 : day - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDateInRange(date: string, checkIn: string, checkOut: string): boolean {
|
function isDateInRange(
|
||||||
|
date: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
): boolean {
|
||||||
return date >= checkIn && date < checkOut;
|
return date >= checkIn && date < checkOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BookingCalendarPage({ params }: PageProps) {
|
export default async function BookingCalendarPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
@@ -59,7 +64,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Belegungskalender">
|
<CmsPageShell account={account} title={t('calendar.title')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
@@ -77,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||||
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||||
|
|
||||||
const bookings = await api.listBookings(acct.id, {
|
const bookings = await api.bookings.list(acct.id, {
|
||||||
from: monthStart,
|
from: monthStart,
|
||||||
to: monthEnd,
|
to: monthEnd,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -101,7 +106,11 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build calendar grid cells
|
// Build calendar grid cells
|
||||||
const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = [];
|
const cells: Array<{
|
||||||
|
day: number | null;
|
||||||
|
occupied: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Empty cells before first day
|
// Empty cells before first day
|
||||||
for (let i = 0; i < firstWeekday; i++) {
|
for (let i = 0; i < firstWeekday; i++) {
|
||||||
@@ -125,19 +134,22 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Belegungskalender">
|
<CmsPageShell account={account} title={t('calendar.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href={`/home/${account}/bookings`}>
|
<Button
|
||||||
<Button variant="ghost" size="icon">
|
variant="ghost"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
asChild
|
||||||
</Link>
|
aria-label={t('calendar.backToBookings')}
|
||||||
<p className="text-muted-foreground">
|
>
|
||||||
Zimmerauslastung im Überblick
|
<Link href={`/home/${account}/bookings`}>
|
||||||
</p>
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground">{t('calendar.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,24 +157,34 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" size="icon" disabled>
|
<Button
|
||||||
<ChevronLeft className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled
|
||||||
|
aria-label={t('calendar.previousMonth')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{MONTH_NAMES[month]} {year}
|
{MONTH_NAMES[month]} {year}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="icon" disabled>
|
<Button
|
||||||
<ChevronRight className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled
|
||||||
|
aria-label={t('calendar.nextMonth')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Weekday Header */}
|
{/* Weekday Header */}
|
||||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||||
{WEEKDAYS.map((day) => (
|
{WEEKDAYS.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
className="text-muted-foreground py-2 text-center text-xs font-medium"
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,13 +202,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
: cell.occupied
|
: cell.occupied
|
||||||
? 'bg-primary/15 text-primary font-semibold'
|
? 'bg-primary/15 text-primary font-semibold'
|
||||||
: 'bg-muted/30 hover:bg-muted/50'
|
: 'bg-muted/30 hover:bg-muted/50'
|
||||||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
|
||||||
>
|
>
|
||||||
{cell.day !== null && (
|
{cell.day !== null && (
|
||||||
<>
|
<>
|
||||||
<span>{cell.day}</span>
|
<span>{cell.day}</span>
|
||||||
{cell.occupied && (
|
{cell.occupied && (
|
||||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-primary" />
|
<span className="bg-primary absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -195,18 +217,18 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
|
<span className="bg-primary/15 inline-block h-3 w-3 rounded-sm" />
|
||||||
Belegt
|
{t('calendar.occupied')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
|
||||||
Frei
|
{t('calendar.free')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
|
||||||
Heute
|
{t('calendar.today')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -217,13 +239,16 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Buchungen in diesem Monat
|
{t('calendar.bookingsThisMonth')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">{bookings.data.length}</p>
|
<p className="text-2xl font-bold">{bookings.data.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{occupiedDates.size} von {daysInMonth} Tagen belegt
|
{t('calendar.daysOccupied', {
|
||||||
|
occupied: occupiedDates.size,
|
||||||
|
total: daysInMonth,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { XCircle } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
interface CancelBookingButtonProps {
|
||||||
|
bookingId: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CancelBookingButton({
|
||||||
|
bookingId,
|
||||||
|
accountId,
|
||||||
|
}: CancelBookingButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations('bookings');
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookings/${bookingId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cancel booking:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="destructive" disabled={isPending}>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('cancel.confirm')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('cancel.title')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('cancel.description')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('cancel.cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleCancel}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? t('cancel.cancelling') : t('cancel.confirm')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { UserCircle, Plus } from 'lucide-react';
|
import { UserCircle, Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,6 +17,7 @@ interface PageProps {
|
|||||||
export default async function GuestsPage({ params }: PageProps) {
|
export default async function GuestsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -26,55 +27,71 @@ export default async function GuestsPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Gäste">
|
<CmsPageShell account={account} title={t('guests.title')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const guests = await api.listGuests(acct.id);
|
const guests = await api.guests.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Gäste">
|
<CmsPageShell account={account} title={t('guests.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Gästeverwaltung</p>
|
<p className="text-muted-foreground">{t('guests.manage')}</p>
|
||||||
<Button>
|
<Button data-test="guests-new-btn">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neuer Gast
|
{t('guests.newGuest')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guests.length === 0 ? (
|
{guests.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<UserCircle className="h-8 w-8" />}
|
icon={<UserCircle className="h-8 w-8" />}
|
||||||
title="Keine Gäste vorhanden"
|
title={t('guests.noGuests')}
|
||||||
description="Legen Sie Ihren ersten Gast an."
|
description={t('guests.addFirst')}
|
||||||
actionLabel="Neuer Gast"
|
actionLabel={t('guests.newGuest')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Gäste ({guests.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('guests.allGuests', { count: guests.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
{t('guests.name')}
|
||||||
<th className="p-3 text-left font-medium">Telefon</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Stadt</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Land</th>
|
{t('guests.email')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('guests.phone')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('guests.city')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('guests.country')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{guests.map((guest: Record<string, unknown>) => (
|
{guests.map((guest: Record<string, unknown>) => (
|
||||||
<tr key={String(guest.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(guest.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
{String(guest.last_name ?? '')}, {String(guest.first_name ?? '')}
|
{String(guest.last_name ?? '')},{' '}
|
||||||
|
{String(guest.first_name ?? '')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(guest.email ?? '—')}</td>
|
<td className="p-3">{String(guest.email ?? '—')}</td>
|
||||||
<td className="p-3">{String(guest.phone ?? '—')}</td>
|
<td className="p-3">{String(guest.phone ?? '—')}</td>
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
import { CreateBookingForm } from '@kit/booking-management/components';
|
import { CreateBookingForm } from '@kit/booking-management/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface Props { params: Promise<{ account: string }> }
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NewBookingPage({ params }: Props) {
|
export default async function NewBookingPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Buchung">
|
<CmsPageShell account={account} title={t('nav.newBooking')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen">
|
<CmsPageShell
|
||||||
<CreateBookingForm
|
account={account}
|
||||||
accountId={acct.id}
|
title={t('newBooking.title')}
|
||||||
account={account}
|
description={t('newBooking.description')}
|
||||||
|
>
|
||||||
|
<CreateBookingForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
|
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
|
||||||
id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0)
|
id: String(r.id),
|
||||||
}))}
|
roomNumber: String(r.room_number),
|
||||||
|
name: String(r.name ?? ''),
|
||||||
|
pricePerNight: Number(r.price_per_night ?? 0),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react';
|
import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import {
|
||||||
|
BOOKING_STATUS_VARIANT as STATUS_BADGE_VARIANT,
|
||||||
|
BOOKING_STATUS_LABEL_KEYS as STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -22,30 +27,14 @@ interface PageProps {
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
const STATUS_BADGE_VARIANT: Record<
|
export default async function BookingsPage({
|
||||||
string,
|
params,
|
||||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
searchParams,
|
||||||
> = {
|
}: PageProps) {
|
||||||
pending: 'secondary',
|
|
||||||
confirmed: 'default',
|
|
||||||
checked_in: 'info',
|
|
||||||
checked_out: 'outline',
|
|
||||||
cancelled: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: 'Ausstehend',
|
|
||||||
confirmed: 'Bestätigt',
|
|
||||||
checked_in: 'Eingecheckt',
|
|
||||||
checked_out: 'Ausgecheckt',
|
|
||||||
cancelled: 'Storniert',
|
|
||||||
no_show: 'Nicht erschienen',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BookingsPage({ params, searchParams }: PageProps) {
|
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -55,7 +44,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Buchungen">
|
<CmsPageShell account={account} title={t('list.title')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
@@ -65,7 +54,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
|
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
|
||||||
const bookingsQuery = client
|
const bookingsQuery = client
|
||||||
@@ -80,16 +69,15 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
|
|
||||||
const { data: bookingsRaw, count: bookingsTotal } = await bookingsQuery;
|
const { data: bookingsRaw, count: bookingsTotal } = await bookingsQuery;
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, unknown>>;
|
||||||
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, any>>;
|
|
||||||
const total = bookingsTotal ?? 0;
|
const total = bookingsTotal ?? 0;
|
||||||
|
|
||||||
// Post-filter by search query (guest name or room name/number)
|
// Post-filter by search query (guest name or room name/number)
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
bookingsData = bookingsData.filter((b) => {
|
bookingsData = bookingsData.filter((booking) => {
|
||||||
const room = b.room as Record<string, string> | null;
|
const room = booking.room as Record<string, string> | null;
|
||||||
const guest = b.guest as Record<string, string> | null;
|
const guest = booking.guest as Record<string, string> | null;
|
||||||
const roomName = (room?.name ?? '').toLowerCase();
|
const roomName = (room?.name ?? '').toLowerCase();
|
||||||
const roomNumber = (room?.room_number ?? '').toLowerCase();
|
const roomNumber = (room?.room_number ?? '').toLowerCase();
|
||||||
const guestFirst = (guest?.first_name ?? '').toLowerCase();
|
const guestFirst = (guest?.first_name ?? '').toLowerCase();
|
||||||
@@ -104,42 +92,41 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeBookings = bookingsData.filter(
|
const activeBookings = bookingsData.filter(
|
||||||
(b) => b.status === 'confirmed' || b.status === 'checked_in',
|
(booking) =>
|
||||||
|
booking.status === 'confirmed' || booking.status === 'checked_in',
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Buchungen">
|
<CmsPageShell account={account} title={t('list.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t('list.manage')}</p>
|
||||||
Zimmer und Buchungen verwalten
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Link href={`/home/${account}/bookings/new`}>
|
<Button data-test="bookings-new-btn" asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/bookings/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neue Buchung
|
{t('list.newBooking')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Zimmer"
|
title={t('rooms.title')}
|
||||||
value={rooms.length}
|
value={rooms.length}
|
||||||
icon={<BedDouble className="h-5 w-5" />}
|
icon={<BedDouble className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Aktive Buchungen"
|
title={t('list.activeBookings')}
|
||||||
value={activeBookings.length}
|
value={activeBookings.length}
|
||||||
icon={<CalendarCheck className="h-5 w-5" />}
|
icon={<CalendarCheck className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Gesamt"
|
title={t('list.total')}
|
||||||
value={total}
|
value={total}
|
||||||
icon={<Euro className="h-5 w-5" />}
|
icon={<Euro className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
@@ -148,23 +135,25 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
{/* Search */}
|
{/* Search */}
|
||||||
<form className="flex items-center gap-2">
|
<form className="flex items-center gap-2">
|
||||||
<div className="relative max-w-sm flex-1">
|
<div className="relative max-w-sm flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search
|
||||||
|
className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="q"
|
name="q"
|
||||||
defaultValue={searchQuery}
|
defaultValue={searchQuery}
|
||||||
placeholder="Gast oder Zimmer suchen…"
|
placeholder={t('list.searchPlaceholder')}
|
||||||
|
aria-label={t('list.searchPlaceholder')}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="secondary" size="sm">
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
Suchen
|
{t('list.search')}
|
||||||
</Button>
|
</Button>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<Link href={`/home/${account}/bookings`}>
|
<Button type="button" variant="ghost" size="sm" asChild>
|
||||||
<Button type="button" variant="ghost" size="sm">
|
<Link href={`/home/${account}/bookings`}>{t('list.reset')}</Link>
|
||||||
Zurücksetzen
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -172,17 +161,13 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
{bookingsData.length === 0 ? (
|
{bookingsData.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<BedDouble className="h-8 w-8" />}
|
icon={<BedDouble className="h-8 w-8" />}
|
||||||
title={
|
title={searchQuery ? t('list.noResults') : t('list.noBookings')}
|
||||||
searchQuery
|
|
||||||
? 'Keine Buchungen gefunden'
|
|
||||||
: 'Keine Buchungen vorhanden'
|
|
||||||
}
|
|
||||||
description={
|
description={
|
||||||
searchQuery
|
searchQuery
|
||||||
? `Keine Ergebnisse für „${searchQuery}".`
|
? t('list.noResultsFor', { query: searchQuery })
|
||||||
: 'Erstellen Sie Ihre erste Buchung, um loszulegen.'
|
: t('list.createFirst')
|
||||||
}
|
}
|
||||||
actionLabel={searchQuery ? undefined : 'Neue Buchung'}
|
actionLabel={searchQuery ? undefined : t('list.newBooking')}
|
||||||
actionHref={
|
actionHref={
|
||||||
searchQuery ? undefined : `/home/${account}/bookings/new`
|
searchQuery ? undefined : `/home/${account}/bookings/new`
|
||||||
}
|
}
|
||||||
@@ -192,32 +177,50 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `Ergebnisse (${bookingsData.length})`
|
? t('list.searchResults', { count: bookingsData.length })
|
||||||
: `Alle Buchungen (${total})`}
|
: t('list.allBookings', { count: total })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Zimmer</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Gast</th>
|
{t('list.room')}
|
||||||
<th className="p-3 text-left font-medium">Anreise</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Abreise</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
{t('list.guest')}
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.checkIn')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.checkOut')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.status')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('list.amount')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{bookingsData.map((booking) => {
|
{bookingsData.map((booking) => {
|
||||||
const room = booking.room as Record<string, string> | null;
|
const room = booking.room as Record<
|
||||||
const guest = booking.guest as Record<string, string> | null;
|
string,
|
||||||
|
string
|
||||||
|
> | null;
|
||||||
|
const guest = booking.guest as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
> | null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={String(booking.id)}
|
key={String(booking.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -235,18 +238,10 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{booking.check_in
|
{formatDate(booking.check_in as string)}
|
||||||
? new Date(
|
|
||||||
String(booking.check_in),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{booking.check_out
|
{formatDate(booking.check_out as string)}
|
||||||
? new Date(
|
|
||||||
String(booking.check_out),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -255,13 +250,17 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
'secondary'
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{STATUS_LABEL[String(booking.status)] ??
|
{t(
|
||||||
String(booking.status)}
|
STATUS_LABEL_KEYS[String(booking.status)] ??
|
||||||
|
String(booking.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{booking.total_price != null
|
{booking.total_price != null
|
||||||
? `${Number(booking.total_price).toFixed(2)} €`
|
? formatCurrencyAmount(
|
||||||
|
booking.total_price as number,
|
||||||
|
)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -274,35 +273,36 @@ export default async function BookingsPage({ params, searchParams }: PageProps)
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && !searchQuery && (
|
{totalPages > 1 && !searchQuery && (
|
||||||
<div className="flex items-center justify-between border-t px-2 py-4">
|
<div className="flex items-center justify-between border-t px-2 py-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Seite {page} von {totalPages} ({total} Einträge)
|
{t('common.page')} {page} {t('common.of')} {totalPages} (
|
||||||
|
{total} {t('common.entries')})
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{page > 1 ? (
|
{page > 1 ? (
|
||||||
<Link
|
<Button variant="outline" size="sm" asChild>
|
||||||
href={`/home/${account}/bookings?page=${page - 1}`}
|
<Link
|
||||||
>
|
href={`/home/${account}/bookings?page=${page - 1}`}
|
||||||
<Button variant="outline" size="sm">
|
>
|
||||||
Zurück
|
{t('common.previous')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
Zurück
|
{t('common.previous')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{page < totalPages ? (
|
{page < totalPages ? (
|
||||||
<Link
|
<Button variant="outline" size="sm" asChild>
|
||||||
href={`/home/${account}/bookings?page=${page + 1}`}
|
<Link
|
||||||
>
|
href={`/home/${account}/bookings?page=${page + 1}`}
|
||||||
<Button variant="outline" size="sm">
|
>
|
||||||
Weiter
|
{t('common.next')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
Weiter
|
{t('common.next')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { BedDouble, Plus } from 'lucide-react';
|
import { BedDouble, Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
|
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -18,6 +19,7 @@ interface PageProps {
|
|||||||
export default async function RoomsPage({ params }: PageProps) {
|
export default async function RoomsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('bookings');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -27,65 +29,90 @@ export default async function RoomsPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Zimmer">
|
<CmsPageShell account={account} title={t('rooms.title')}>
|
||||||
<AccountNotFound />
|
<AccountNotFound />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Zimmer">
|
<CmsPageShell account={account} title={t('rooms.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Zimmerverwaltung</p>
|
<p className="text-muted-foreground">{t('rooms.manage')}</p>
|
||||||
<Button>
|
<Button data-test="rooms-new-btn">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neues Zimmer
|
{t('rooms.newRoom')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rooms.length === 0 ? (
|
{rooms.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<BedDouble className="h-8 w-8" />}
|
icon={<BedDouble className="h-8 w-8" />}
|
||||||
title="Keine Zimmer vorhanden"
|
title={t('rooms.noRooms')}
|
||||||
description="Fügen Sie Ihr erstes Zimmer hinzu."
|
description={t('rooms.addFirst')}
|
||||||
actionLabel="Neues Zimmer"
|
actionLabel={t('rooms.newRoom')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Zimmer ({rooms.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('rooms.allRooms', { count: rooms.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Zimmernr.</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
{t('rooms.roomNumber')}
|
||||||
<th className="p-3 text-left font-medium">Typ</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-right font-medium">Preis/Nacht</th>
|
{t('rooms.name')}
|
||||||
<th className="p-3 text-center font-medium">Aktiv</th>
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('rooms.type')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('rooms.capacity')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('rooms.price')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-center font-medium">
|
||||||
|
{t('rooms.active')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room: Record<string, unknown>) => (
|
{rooms.map((room: Record<string, unknown>) => (
|
||||||
<tr key={String(room.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(room.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-mono text-xs">
|
<td className="p-3 font-mono text-xs">
|
||||||
{String(room.room_number ?? '—')}
|
{String(room.room_number ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 font-medium">{String(room.name ?? '—')}</td>
|
<td className="p-3 font-medium">
|
||||||
<td className="p-3">
|
{String(room.name ?? '—')}
|
||||||
<Badge variant="outline">{String(room.room_type ?? '—')}</Badge>
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{String(room.room_type ?? '—')}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{String(room.capacity ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">{String(room.capacity ?? '—')}</td>
|
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{room.price_per_night != null
|
{room.price_per_night != null
|
||||||
? `${Number(room.price_per_night).toFixed(2)} €`
|
? formatCurrencyAmount(
|
||||||
|
room.price_per_night as number,
|
||||||
|
)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
|
||||||
|
import { markAttendance } from '@kit/course-management/actions/course-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttendanceGridProps {
|
||||||
|
sessionId: string;
|
||||||
|
participants: Participant[];
|
||||||
|
initialAttendance: Map<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceGrid({
|
||||||
|
sessionId,
|
||||||
|
participants,
|
||||||
|
initialAttendance,
|
||||||
|
}: AttendanceGridProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [attendance, setAttendance] = useState<Map<string, boolean>>(
|
||||||
|
() => new Map(initialAttendance),
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const toggle = (participantId: string) => {
|
||||||
|
setAttendance((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(participantId, !prev.get(participantId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const promises = participants.map((p) =>
|
||||||
|
markAttendance({
|
||||||
|
sessionId,
|
||||||
|
participantId: p.id,
|
||||||
|
present: attendance.get(p.id) ?? false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
toast.success('Anwesenheit gespeichert');
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch {
|
||||||
|
toast.error('Fehler beim Speichern der Anwesenheit');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Keine Teilnehmer in diesem Kurs
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Teilnehmer
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-center font-medium">
|
||||||
|
Anwesend
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||||
|
onClick={() => toggle(p.id)}
|
||||||
|
>
|
||||||
|
<td className="p-3 font-medium">
|
||||||
|
{p.lastName}, {p.firstName}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attendance.get(p.id) ?? false}
|
||||||
|
onChange={() => toggle(p.id)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={isSaving || isPending}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{isSaving ? 'Wird gespeichert...' : 'Anwesenheit speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,84 @@
|
|||||||
import { ClipboardCheck, Calendar } from 'lucide-react';
|
import { ClipboardCheck, Calendar } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
|
||||||
|
import { AttendanceGrid } from './attendance-grid';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; courseId: string }>;
|
params: Promise<{ account: string; courseId: string }>;
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AttendancePage({ params, searchParams }: PageProps) {
|
export default async function AttendancePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
const { account, courseId } = await params;
|
const { account, courseId } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, sessions, participants] = await Promise.all([
|
const [course, sessions, participants] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getSessions(courseId),
|
api.sessions.list(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|
||||||
const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record<string, unknown>).id) : null);
|
const selectedSessionId =
|
||||||
|
(search.session as string) ??
|
||||||
|
(sessions.length > 0
|
||||||
|
? String((sessions[0] as Record<string, unknown>).id)
|
||||||
|
: null);
|
||||||
|
|
||||||
const attendance = selectedSessionId
|
const attendance = selectedSessionId
|
||||||
? await api.getAttendance(selectedSessionId)
|
? await api.attendance.getBySession(selectedSessionId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const attendanceMap = new Map(
|
const attendanceMap = new Map(
|
||||||
attendance.map((a: Record<string, unknown>) => [String(a.participant_id), Boolean(a.present)]),
|
attendance.map((a: Record<string, unknown>) => [
|
||||||
|
String(a.participant_id),
|
||||||
|
Boolean(a.present),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const participantList = participants.map((p: Record<string, unknown>) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
firstName: String(p.first_name ?? ''),
|
||||||
|
lastName: String(p.last_name ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Anwesenheit">
|
<CmsPageShell account={account} title={t('attendance.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Anwesenheit</h1>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{String((course as Record<string, unknown>).name)}
|
{String((course as Record<string, unknown>).name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session Selector */}
|
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Calendar className="h-8 w-8" />}
|
icon={<Calendar className="h-8 w-8" />}
|
||||||
title="Keine Termine vorhanden"
|
title={t('attendance.noSessions')}
|
||||||
description="Erstellen Sie zuerst Termine für diesen Kurs."
|
description={t('attendance.noSessionsDescription')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Termin auswählen</CardTitle>
|
<CardTitle>{t('attendance.selectSession')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -70,9 +89,12 @@ export default async function AttendancePage({ params, searchParams }: PageProps
|
|||||||
key={String(s.id)}
|
key={String(s.id)}
|
||||||
href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`}
|
href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`}
|
||||||
>
|
>
|
||||||
<Badge variant={isSelected ? 'default' : 'outline'} className="cursor-pointer px-3 py-1">
|
<Badge
|
||||||
|
variant={isSelected ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer px-3 py-1"
|
||||||
|
>
|
||||||
{s.session_date
|
{s.session_date
|
||||||
? new Date(String(s.session_date)).toLocaleDateString('de-DE')
|
? formatDate(s.session_date as string)
|
||||||
: String(s.id)}
|
: String(s.id)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
@@ -82,47 +104,24 @@ export default async function AttendancePage({ params, searchParams }: PageProps
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Attendance Grid */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<ClipboardCheck className="h-5 w-5" />
|
<ClipboardCheck className="h-5 w-5" />
|
||||||
Anwesenheitsliste
|
{t('attendance.attendanceList')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{participants.length === 0 ? (
|
{selectedSessionId ? (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
<AttendanceGrid
|
||||||
Keine Teilnehmer in diesem Kurs
|
sessionId={selectedSessionId}
|
||||||
</p>
|
participants={participantList}
|
||||||
|
initialAttendance={attendanceMap}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
<table className="w-full text-sm">
|
{t('attendance.selectSessionPrompt')}
|
||||||
<thead>
|
</p>
|
||||||
<tr className="border-b bg-muted/50">
|
|
||||||
<th className="p-3 text-left font-medium">Teilnehmer</th>
|
|
||||||
<th className="p-3 text-center font-medium">Anwesend</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{participants.map((p: Record<string, unknown>) => (
|
|
||||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">
|
|
||||||
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={attendanceMap.get(String(p.id)) ?? false}
|
|
||||||
className="h-4 w-4 rounded border-gray-300"
|
|
||||||
aria-label={`Anwesenheit ${String(p.last_name)}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createSession } from '@kit/course-management/actions/course-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
courseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateSessionDialog({ courseId }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(createSession, {
|
||||||
|
successMessage: 'Termin erstellt',
|
||||||
|
errorMessage: 'Fehler beim Erstellen',
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Neuer Termin
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
execute({
|
||||||
|
courseId,
|
||||||
|
sessionDate: fd.get('sessionDate') as string,
|
||||||
|
startTime: fd.get('startTime') as string,
|
||||||
|
endTime: fd.get('endTime') as string,
|
||||||
|
notes: (fd.get('notes') as string) || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuen Termin erstellen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sessionDate">Datum *</Label>
|
||||||
|
<Input id="sessionDate" name="sessionDate" type="date" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startTime">Beginn *</Label>
|
||||||
|
<Input id="startTime" name="startTime" type="time" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endTime">Ende *</Label>
|
||||||
|
<Input id="endTime" name="endTime" type="time" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notizen</Label>
|
||||||
|
<Input id="notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
data-test="session-submit-btn"
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Termin erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { deleteCourse } from '@kit/course-management/actions/course-actions';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
courseId: string;
|
||||||
|
accountSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteCourseButton({ courseId, accountSlug }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(deleteCourse, {
|
||||||
|
successMessage: 'Kurs wurde abgesagt',
|
||||||
|
errorMessage: 'Fehler beim Absagen',
|
||||||
|
onSuccess: () => router.push(`/home/${accountSlug}/courses`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPending}
|
||||||
|
data-test="course-cancel-btn"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Kurs absagen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Kurs absagen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Der Kurs wird auf den Status "Abgesagt" gesetzt. Diese
|
||||||
|
Aktion kann rückgängig gemacht werden.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel data-test="course-cancel-dismiss-btn">
|
||||||
|
Abbrechen
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
data-test="course-cancel-confirm-btn"
|
||||||
|
onClick={() => execute({ courseId })}
|
||||||
|
>
|
||||||
|
Absagen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { CreateCourseForm } from '@kit/course-management/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ account: string; courseId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditCoursePage({ params }: PageProps) {
|
||||||
|
const { account, courseId } = await params;
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
const course = await api.courses.getById(courseId);
|
||||||
|
if (!course) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const c = course as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={`${String(c.name)} — ${t('pages.editCourseTitle')}`}
|
||||||
|
>
|
||||||
|
<CreateCourseForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
courseId={courseId}
|
||||||
|
initialData={{
|
||||||
|
courseNumber: String(c.course_number ?? ''),
|
||||||
|
name: String(c.name ?? ''),
|
||||||
|
description: String(c.description ?? ''),
|
||||||
|
startDate: String(c.start_date ?? ''),
|
||||||
|
endDate: String(c.end_date ?? ''),
|
||||||
|
fee: Number(c.fee ?? 0),
|
||||||
|
reducedFee: Number(c.reduced_fee ?? 0),
|
||||||
|
capacity: Number(c.capacity ?? 20),
|
||||||
|
minParticipants: Number(c.min_participants ?? 5),
|
||||||
|
status: String(c.status ?? 'planned'),
|
||||||
|
registrationDeadline: String(c.registration_deadline ?? ''),
|
||||||
|
notes: String(c.notes ?? ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,182 +1,289 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react';
|
import {
|
||||||
|
GraduationCap,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
Euro,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import {
|
||||||
|
COURSE_STATUS_VARIANT,
|
||||||
|
COURSE_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
|
import { CreateSessionDialog } from './create-session-dialog';
|
||||||
|
import { DeleteCourseButton } from './delete-course-button';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; courseId: string }>;
|
params: Promise<{ account: string; courseId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
planned: 'Geplant', open: 'Offen', running: 'Laufend',
|
|
||||||
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
|
||||||
planned: 'secondary', open: 'default', running: 'info',
|
|
||||||
completed: 'outline', cancelled: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function CourseDetailPage({ params }: PageProps) {
|
export default async function CourseDetailPage({ params }: PageProps) {
|
||||||
const { account, courseId } = await params;
|
const { account, courseId } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, participants, sessions] = await Promise.all([
|
const [course, participants, sessions] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
api.getSessions(courseId),
|
api.sessions.list(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|
||||||
const c = course as Record<string, unknown>;
|
const courseData = course as Record<string, unknown>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={String(c.name)}>
|
<CmsPageShell account={account} title={String(courseData.name)}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/home/${account}/courses/${courseId}/edit`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
{t('detail.edit')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteCourseButton courseId={courseId} accountSlug={account} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<GraduationCap className="h-5 w-5 text-primary" />
|
<GraduationCap className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Name</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
<p className="font-semibold">{String(c.name)}</p>
|
{t('detail.name')}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">{String(courseData.name)}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Clock className="h-5 w-5 text-primary" />
|
<Clock className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Status</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
<Badge variant={STATUS_VARIANT[String(c.status)] ?? 'secondary'}>
|
{t('common.status')}
|
||||||
{STATUS_LABEL[String(c.status)] ?? String(c.status)}
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
COURSE_STATUS_VARIANT[String(courseData.status)] ??
|
||||||
|
'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
COURSE_STATUS_LABEL_KEYS[String(courseData.status)] ??
|
||||||
|
String(courseData.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<User className="h-5 w-5 text-primary" />
|
<User className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Dozent</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
<p className="font-semibold">{String(c.instructor_id ?? '—')}</p>
|
{t('detail.instructor')}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{String(courseData.instructor_id ?? '—')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Calendar className="h-5 w-5 text-primary" />
|
<Calendar className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Beginn – Ende</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('detail.dateRange')}
|
||||||
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'}
|
{formatDate(courseData.start_date as string)}
|
||||||
{' – '}
|
{' – '}
|
||||||
{c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'}
|
{formatDate(courseData.end_date as string)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Euro className="h-5 w-5 text-primary" />
|
<Euro className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Gebühr</p>
|
<p className="text-muted-foreground text-xs">{t('list.fee')}</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{c.fee != null ? `${Number(c.fee).toFixed(2)} €` : '—'}
|
{courseData.fee != null
|
||||||
|
? formatCurrencyAmount(courseData.fee as number)
|
||||||
|
: '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Teilnehmer</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('detail.participants')}
|
||||||
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{participants.length} / {String(c.capacity ?? '∞')}
|
{participants.length} / {String(courseData.capacity ?? '∞')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Teilnehmer Section */}
|
{/* Participants Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Teilnehmer</CardTitle>
|
<CardTitle>{t('detail.participants')}</CardTitle>
|
||||||
<Link href={`/home/${account}/courses/${courseId}/participants`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">Alle anzeigen</Button>
|
<Link href={`/home/${account}/courses/${courseId}/participants`}>
|
||||||
</Link>
|
{t('detail.viewAll')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
{t('detail.name')}
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('detail.email')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('detail.date')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Teilnehmer</td></tr>
|
<tr>
|
||||||
) : participants.map((p: Record<string, unknown>) => (
|
<td
|
||||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
colSpan={4}
|
||||||
<td className="p-3 font-medium">{String(p.last_name ?? '')}, {String(p.first_name ?? '')}</td>
|
className="text-muted-foreground p-6 text-center"
|
||||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
>
|
||||||
<td className="p-3"><Badge variant="outline">{String(p.status ?? '—')}</Badge></td>
|
{t('detail.noParticipants')}
|
||||||
<td className="p-3">{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
participants.map((p: Record<string, unknown>) => (
|
||||||
|
<tr
|
||||||
|
key={String(p.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
|
<td className="p-3 font-medium">
|
||||||
|
{String(p.last_name ?? '')},{' '}
|
||||||
|
{String(p.first_name ?? '')}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{String(p.status ?? '—')}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{formatDate(p.enrolled_at as string)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Termine Section */}
|
{/* Sessions Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Termine</CardTitle>
|
<CardTitle>{t('detail.sessions')}</CardTitle>
|
||||||
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm">Anwesenheit</Button>
|
<CreateSessionDialog courseId={courseId} />
|
||||||
</Link>
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
|
||||||
|
{t('detail.attendance')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Beginn</th>
|
{t('detail.date')}
|
||||||
<th className="p-3 text-left font-medium">Ende</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Abgesagt?</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.startDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.endDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('detail.cancelled')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<tr><td colSpan={4} className="p-6 text-center text-muted-foreground">Keine Termine</td></tr>
|
<tr>
|
||||||
) : sessions.map((s: Record<string, unknown>) => (
|
<td
|
||||||
<tr key={String(s.id)} className="border-b hover:bg-muted/30">
|
colSpan={4}
|
||||||
<td className="p-3">{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}</td>
|
className="text-muted-foreground p-6 text-center"
|
||||||
<td className="p-3">{String(s.start_time ?? '—')}</td>
|
>
|
||||||
<td className="p-3">{String(s.end_time ?? '—')}</td>
|
{t('detail.noSessions')}
|
||||||
<td className="p-3">{s.cancelled ? <Badge variant="destructive">Ja</Badge> : '—'}</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
sessions.map((s: Record<string, unknown>) => (
|
||||||
|
<tr
|
||||||
|
key={String(s.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
{formatDate(s.session_date as string)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{String(s.start_time ?? '—')}</td>
|
||||||
|
<td className="p-3">{String(s.end_time ?? '—')}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{s.cancelled ? (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{t('common.yes')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Plus, Users } from 'lucide-react';
|
import { Plus, Users } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
|
||||||
@@ -16,17 +16,20 @@ interface PageProps {
|
|||||||
params: Promise<{ account: string; courseId: string }>;
|
params: Promise<{ account: string; courseId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
const STATUS_VARIANT: Record<
|
||||||
|
string,
|
||||||
|
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||||
|
> = {
|
||||||
enrolled: 'default',
|
enrolled: 'default',
|
||||||
waitlisted: 'secondary',
|
waitlisted: 'secondary',
|
||||||
cancelled: 'destructive',
|
cancelled: 'destructive',
|
||||||
completed: 'outline',
|
completed: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const ENROLLMENT_STATUS_LABEL: Record<string, string> = {
|
||||||
enrolled: 'Angemeldet',
|
enrolled: 'Eingeschrieben',
|
||||||
waitlisted: 'Warteliste',
|
waitlisted: 'Warteliste',
|
||||||
cancelled: 'Abgemeldet',
|
cancelled: 'Storniert',
|
||||||
completed: 'Abgeschlossen',
|
completed: 'Abgeschlossen',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,71 +37,91 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
|||||||
const { account, courseId } = await params;
|
const { account, courseId } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, participants] = await Promise.all([
|
const [course, participants] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <div>Kurs nicht gefunden</div>;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Teilnehmer">
|
<CmsPageShell account={account} title={t('participants.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Teilnehmer</h1>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{String((course as Record<string, unknown>).name)} — {participants.length} Teilnehmer
|
{String((course as Record<string, unknown>).name)} —{' '}
|
||||||
|
{participants.length} {t('participants.title')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button data-test="participants-add-btn">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Teilnehmer anmelden
|
{t('participants.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Users className="h-8 w-8" />}
|
icon={<Users className="h-8 w-8" />}
|
||||||
title="Keine Teilnehmer"
|
title={t('participants.none')}
|
||||||
description="Melden Sie den ersten Teilnehmer für diesen Kurs an."
|
description={t('participants.noneDescription')}
|
||||||
actionLabel="Teilnehmer anmelden"
|
actionLabel={t('participants.add')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Teilnehmer ({participants.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('participants.allTitle', { count: participants.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
{t('common.name')}
|
||||||
<th className="p-3 text-left font-medium">Telefon</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Anmeldedatum</th>
|
{t('common.email')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.phone')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('enrollment.registrationDate')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{participants.map((p: Record<string, unknown>) => (
|
{participants.map((p: Record<string, unknown>) => (
|
||||||
<tr key={String(p.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(p.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}
|
{String(p.last_name ?? '')},{' '}
|
||||||
|
{String(p.first_name ?? '')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(p.email ?? '—')}</td>
|
<td className="p-3">{String(p.email ?? '—')}</td>
|
||||||
<td className="p-3">{String(p.phone ?? '—')}</td>
|
<td className="p-3">{String(p.phone ?? '—')}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={STATUS_VARIANT[String(p.status)] ?? 'secondary'}>
|
<Badge
|
||||||
{STATUS_LABEL[String(p.status)] ?? String(p.status)}
|
variant={
|
||||||
|
STATUS_VARIANT[String(p.status)] ?? 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ENROLLMENT_STATUS_LABEL[String(p.status)] ??
|
||||||
|
String(p.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{p.enrolled_at
|
{formatDate(p.enrolled_at as string)}
|
||||||
? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,38 +1,23 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'Januar',
|
|
||||||
'Februar',
|
|
||||||
'März',
|
|
||||||
'April',
|
|
||||||
'Mai',
|
|
||||||
'Juni',
|
|
||||||
'Juli',
|
|
||||||
'August',
|
|
||||||
'September',
|
|
||||||
'Oktober',
|
|
||||||
'November',
|
|
||||||
'Dezember',
|
|
||||||
];
|
|
||||||
|
|
||||||
function getDaysInMonth(year: number, month: number): number {
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
return new Date(year, month + 1, 0).getDate();
|
return new Date(year, month + 1, 0).getDate();
|
||||||
}
|
}
|
||||||
@@ -42,9 +27,14 @@ function getFirstWeekday(year: number, month: number): number {
|
|||||||
return day === 0 ? 6 : day - 1;
|
return day === 0 ? 6 : day - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CourseCalendarPage({ params }: PageProps) {
|
export default async function CourseCalendarPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -55,11 +45,20 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
|
const courses = await api.courses.list(acct.id, { page: 1, pageSize: 100 });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const monthParam = search.month as string | undefined;
|
||||||
const month = now.getMonth();
|
let year: number;
|
||||||
|
let month: number;
|
||||||
|
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
||||||
|
const [y, m] = monthParam.split('-').map(Number);
|
||||||
|
year = y!;
|
||||||
|
month = m! - 1;
|
||||||
|
} else {
|
||||||
|
year = now.getFullYear();
|
||||||
|
month = now.getMonth();
|
||||||
|
}
|
||||||
const daysInMonth = getDaysInMonth(year, month);
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
const firstWeekday = getFirstWeekday(year, month);
|
const firstWeekday = getFirstWeekday(year, month);
|
||||||
|
|
||||||
@@ -67,10 +66,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
const courseDates = new Set<number>();
|
const courseDates = new Set<number>();
|
||||||
|
|
||||||
for (const course of courses.data) {
|
for (const course of courses.data) {
|
||||||
const c = course as Record<string, unknown>;
|
const courseItem = course as Record<string, unknown>;
|
||||||
if (c.status === 'cancelled') continue;
|
if (courseItem.status === 'cancelled') continue;
|
||||||
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
|
const startDate = courseItem.start_date
|
||||||
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
|
? new Date(String(courseItem.start_date))
|
||||||
|
: null;
|
||||||
|
const endDate = courseItem.end_date
|
||||||
|
? new Date(String(courseItem.end_date))
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!startDate) continue;
|
if (!startDate) continue;
|
||||||
|
|
||||||
@@ -86,7 +89,11 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build calendar grid
|
// Build calendar grid
|
||||||
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
|
const cells: Array<{
|
||||||
|
day: number | null;
|
||||||
|
hasCourse: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (let i = 0; i < firstWeekday; i++) {
|
for (let i = 0; i < firstWeekday; i++) {
|
||||||
cells.push({ day: null, hasCourse: false, isToday: false });
|
cells.push({ day: null, hasCourse: false, isToday: false });
|
||||||
@@ -96,7 +103,10 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
cells.push({
|
cells.push({
|
||||||
day: d,
|
day: d,
|
||||||
hasCourse: courseDates.has(d),
|
hasCourse: courseDates.has(d),
|
||||||
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
|
isToday:
|
||||||
|
d === now.getDate() &&
|
||||||
|
month === now.getMonth() &&
|
||||||
|
year === now.getFullYear(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,24 +115,31 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeCourses = courses.data.filter(
|
const activeCourses = courses.data.filter(
|
||||||
(c: Record<string, unknown>) =>
|
(courseItem: Record<string, unknown>) =>
|
||||||
c.status === 'open' || c.status === 'running',
|
courseItem.status === 'open' || courseItem.status === 'running',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use translation arrays for weekdays and months
|
||||||
|
const WEEKDAYS = t.raw('calendar.weekdays') as string[];
|
||||||
|
const MONTH_NAMES = t.raw('calendar.months') as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Kurskalender">
|
<CmsPageShell account={account} title={t('pages.calendarTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href={`/home/${account}/courses`}>
|
<Button
|
||||||
<Button variant="ghost" size="icon">
|
variant="ghost"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
asChild
|
||||||
</Link>
|
aria-label={t('calendar.backToCourses')}
|
||||||
<p className="text-muted-foreground">
|
>
|
||||||
Kurstermine im Überblick
|
<Link href={`/home/${account}/courses`}>
|
||||||
</p>
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground">{t('calendar.overview')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,24 +147,50 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" size="icon" disabled>
|
<Button
|
||||||
<ChevronLeft className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
aria-label={t('calendar.previousMonth')}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/courses/calendar?month=${
|
||||||
|
month === 0
|
||||||
|
? `${year - 1}-12`
|
||||||
|
: `${year}-${String(month).padStart(2, '0')}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{MONTH_NAMES[month]} {year}
|
{MONTH_NAMES[month]} {year}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="icon" disabled>
|
<Button
|
||||||
<ChevronRight className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
aria-label={t('calendar.nextMonth')}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/courses/calendar?month=${
|
||||||
|
month === 11
|
||||||
|
? `${year + 1}-01`
|
||||||
|
: `${year}-${String(month + 2).padStart(2, '0')}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Weekday Header */}
|
{/* Weekday Header */}
|
||||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||||
{WEEKDAYS.map((day) => (
|
{WEEKDAYS.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
className="text-muted-foreground py-2 text-center text-xs font-medium"
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,15 +206,15 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
cell.day === null
|
cell.day === null
|
||||||
? 'bg-transparent'
|
? 'bg-transparent'
|
||||||
: cell.hasCourse
|
: cell.hasCourse
|
||||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
|
? 'bg-emerald-500/15 font-semibold text-emerald-700 dark:text-emerald-400'
|
||||||
: 'bg-muted/30 hover:bg-muted/50'
|
: 'bg-muted/30 hover:bg-muted/50'
|
||||||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
|
||||||
>
|
>
|
||||||
{cell.day !== null && (
|
{cell.day !== null && (
|
||||||
<>
|
<>
|
||||||
<span>{cell.day}</span>
|
<span>{cell.day}</span>
|
||||||
{cell.hasCourse && (
|
{cell.hasCourse && (
|
||||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
<span className="absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -180,18 +223,18 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
|
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
|
||||||
Kurstag
|
{t('calendar.courseDay')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
|
||||||
Frei
|
{t('calendar.free')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
|
||||||
Heute
|
{t('calendar.today')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -200,12 +243,14 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
{/* Active Courses this Month */}
|
{/* Active Courses this Month */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Aktive Kurse ({activeCourses.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('calendar.activeCourses', { count: activeCourses.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeCourses.length === 0 ? (
|
{activeCourses.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Keine aktiven Kurse in diesem Monat.
|
{t('calendar.noActiveCourses')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -221,18 +266,19 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
|||||||
>
|
>
|
||||||
{String(course.name)}
|
{String(course.name)}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{course.start_date
|
{formatDate(course.start_date as string)} –{' '}
|
||||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
{formatDate(course.end_date as string)}
|
||||||
: '—'}{' '}
|
|
||||||
–{' '}
|
|
||||||
{course.end_date
|
|
||||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
|
<Badge
|
||||||
{String(course.status) === 'running' ? 'Laufend' : 'Offen'}
|
variant={
|
||||||
|
String(course.status) === 'running' ? 'info' : 'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{String(course.status) === 'running'
|
||||||
|
? t('status.running')
|
||||||
|
: t('status.open')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { createCategory } from '@kit/course-management/actions/course-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface CreateCategoryDialogProps {
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCategoryDialog({ accountId }: CreateCategoryDialogProps) {
|
||||||
|
const t = useTranslations('courses');
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(createCategory, {
|
||||||
|
successMessage: 'Kategorie erstellt',
|
||||||
|
errorMessage: 'Fehler beim Erstellen der Kategorie',
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
execute({
|
||||||
|
accountId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[execute, accountId, name, description],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button size="sm" />}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Neue Kategorie
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neue Kategorie</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Erstellen Sie eine neue Kurskategorie.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cat-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="cat-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t('categories.namePlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={128}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cat-description">Beschreibung (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="cat-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t('categories.descriptionPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { FolderTree, Plus } from 'lucide-react';
|
import { FolderTree } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
import { CreateCategoryDialog } from './create-category-dialog';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,6 +18,7 @@ interface PageProps {
|
|||||||
export default async function CategoriesPage({ params }: PageProps) {
|
export default async function CategoriesPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -27,46 +29,54 @@ export default async function CategoriesPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const categories = await api.listCategories(acct.id);
|
const categories = await api.referenceData.listCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Kategorien">
|
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Kurskategorien verwalten</p>
|
<p className="text-muted-foreground">{t('categories.manage')}</p>
|
||||||
<Button>
|
<CreateCategoryDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Neue Kategorie
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FolderTree className="h-8 w-8" />}
|
icon={<FolderTree className="h-8 w-8" />}
|
||||||
title="Keine Kategorien vorhanden"
|
title={t('categories.noCategories')}
|
||||||
description="Erstellen Sie Ihre erste Kurskategorie."
|
description={t('categories.manage')}
|
||||||
actionLabel="Neue Kategorie"
|
actionLabel={t('categories.newCategory')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('categories.allTitle', { count: categories.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
{t('common.name')}
|
||||||
<th className="p-3 text-left font-medium">Übergeordnet</th>
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.description')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.parent')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categories.map((cat: Record<string, unknown>) => (
|
{categories.map((cat: Record<string, unknown>) => (
|
||||||
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(cat.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
<td className="p-3 font-medium">{String(cat.name)}</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="text-muted-foreground p-3">
|
||||||
{String(cat.description ?? '—')}
|
{String(cat.description ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
|
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { createInstructor } from '@kit/course-management/actions/course-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { Textarea } from '@kit/ui/textarea';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface CreateInstructorDialogProps {
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateInstructorDialog({
|
||||||
|
accountId,
|
||||||
|
}: CreateInstructorDialogProps) {
|
||||||
|
const t = useTranslations('courses');
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [qualifications, setQualifications] = useState('');
|
||||||
|
const [hourlyRate, setHourlyRate] = useState('');
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(createInstructor, {
|
||||||
|
successMessage: 'Dozent erstellt',
|
||||||
|
errorMessage: 'Fehler beim Erstellen des Dozenten',
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
setFirstName('');
|
||||||
|
setLastName('');
|
||||||
|
setEmail('');
|
||||||
|
setPhone('');
|
||||||
|
setQualifications('');
|
||||||
|
setHourlyRate('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!firstName.trim() || !lastName.trim()) return;
|
||||||
|
execute({
|
||||||
|
accountId,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
phone: phone.trim() || undefined,
|
||||||
|
qualifications: qualifications.trim() || undefined,
|
||||||
|
hourlyRate: hourlyRate ? Number(hourlyRate) : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
execute,
|
||||||
|
accountId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
qualifications,
|
||||||
|
hourlyRate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button size="sm" />}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Neuer Dozent
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuer Dozent</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Einen neuen Dozenten zum Dozentenpool hinzufuegen.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-first-name">Vorname</Label>
|
||||||
|
<Input
|
||||||
|
id="inst-first-name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
placeholder={t('instructors.firstNamePlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={128}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-last-name">Nachname</Label>
|
||||||
|
<Input
|
||||||
|
id="inst-last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
placeholder={t('instructors.lastNamePlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={128}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-email">E-Mail (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inst-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="dozent@beispiel.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-phone">Telefon (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inst-phone"
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+49 123 456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-qualifications">
|
||||||
|
Qualifikationen (optional)
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="inst-qualifications"
|
||||||
|
value={qualifications}
|
||||||
|
onChange={(e) => setQualifications(e.target.value)}
|
||||||
|
placeholder={t('instructors.qualificationsPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inst-hourly-rate">Stundensatz (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inst-hourly-rate"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={hourlyRate}
|
||||||
|
onChange={(e) => setHourlyRate(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !firstName.trim() || !lastName.trim()}
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { GraduationCap, Plus } from 'lucide-react';
|
import { GraduationCap } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
import { CreateInstructorDialog } from './create-instructor-dialog';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,6 +19,7 @@ interface PageProps {
|
|||||||
export default async function InstructorsPage({ params }: PageProps) {
|
export default async function InstructorsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -27,55 +30,70 @@ export default async function InstructorsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const instructors = await api.listInstructors(acct.id);
|
const instructors = await api.referenceData.listInstructors(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Dozenten">
|
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Dozentenpool verwalten</p>
|
<p className="text-muted-foreground">{t('instructors.manage')}</p>
|
||||||
<Button>
|
<CreateInstructorDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Neuer Dozent
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{instructors.length === 0 ? (
|
{instructors.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<GraduationCap className="h-8 w-8" />}
|
icon={<GraduationCap className="h-8 w-8" />}
|
||||||
title="Keine Dozenten vorhanden"
|
title={t('instructors.noInstructors')}
|
||||||
description="Fügen Sie Ihren ersten Dozenten hinzu."
|
description={t('instructors.manage')}
|
||||||
actionLabel="Neuer Dozent"
|
actionLabel={t('instructors.newInstructor')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Dozenten ({instructors.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('instructors.allTitle', { count: instructors.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
{t('common.name')}
|
||||||
<th className="p-3 text-left font-medium">Telefon</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Qualifikation</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-right font-medium">Stundensatz</th>
|
{t('common.email')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.phone')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('instructors.qualification')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('instructors.hourlyRate')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{instructors.map((inst: Record<string, unknown>) => (
|
{instructors.map((inst: Record<string, unknown>) => (
|
||||||
<tr key={String(inst.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(inst.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
{String(inst.last_name ?? '')}, {String(inst.first_name ?? '')}
|
{String(inst.last_name ?? '')},{' '}
|
||||||
|
{String(inst.first_name ?? '')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(inst.email ?? '—')}</td>
|
<td className="p-3">{String(inst.email ?? '—')}</td>
|
||||||
<td className="p-3">{String(inst.phone ?? '—')}</td>
|
<td className="p-3">{String(inst.phone ?? '—')}</td>
|
||||||
<td className="p-3">{String(inst.qualification ?? '—')}</td>
|
<td className="p-3">
|
||||||
|
{String(inst.qualification ?? '—')}
|
||||||
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{inst.hourly_rate != null
|
{inst.hourly_rate != null
|
||||||
? `${Number(inst.hourly_rate).toFixed(2)} €`
|
? formatCurrencyAmount(inst.hourly_rate as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { createLocation } from '@kit/course-management/actions/course-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface CreateLocationDialogProps {
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateLocationDialog({ accountId }: CreateLocationDialogProps) {
|
||||||
|
const t = useTranslations('courses');
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [room, setRoom] = useState('');
|
||||||
|
const [capacity, setCapacity] = useState('');
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(createLocation, {
|
||||||
|
successMessage: 'Ort erstellt',
|
||||||
|
errorMessage: 'Fehler beim Erstellen des Ortes',
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
setName('');
|
||||||
|
setAddress('');
|
||||||
|
setRoom('');
|
||||||
|
setCapacity('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
execute({
|
||||||
|
accountId,
|
||||||
|
name: name.trim(),
|
||||||
|
address: address.trim() || undefined,
|
||||||
|
room: room.trim() || undefined,
|
||||||
|
capacity: capacity ? Number(capacity) : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[execute, accountId, name, address, room, capacity],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button size="sm" />}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Neuer Ort
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuer Ort</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Einen neuen Kurs- oder Veranstaltungsort hinzufuegen.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t('locations.namePlaceholder')}
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={128}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-address">Adresse (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-address"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder={t('locations.addressPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-room">Raum (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-room"
|
||||||
|
value={room}
|
||||||
|
onChange={(e) => setRoom(e.target.value)}
|
||||||
|
placeholder={t('locations.roomPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-capacity">Kapazitaet (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-capacity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={capacity}
|
||||||
|
onChange={(e) => setCapacity(e.target.value)}
|
||||||
|
placeholder="z. B. 30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { MapPin, Plus } from 'lucide-react';
|
import { MapPin } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
import { CreateLocationDialog } from './create-location-dialog';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,6 +18,7 @@ interface PageProps {
|
|||||||
export default async function LocationsPage({ params }: PageProps) {
|
export default async function LocationsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -27,45 +29,55 @@ export default async function LocationsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const locations = await api.listLocations(acct.id);
|
const locations = await api.referenceData.listLocations(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Orte">
|
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
|
<p className="text-muted-foreground">{t('locations.manage')}</p>
|
||||||
<Button>
|
<CreateLocationDialog accountId={acct.id} />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Neuer Ort
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{locations.length === 0 ? (
|
{locations.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<MapPin className="h-8 w-8" />}
|
icon={<MapPin className="h-8 w-8" />}
|
||||||
title="Keine Orte vorhanden"
|
title={t('locations.noLocations')}
|
||||||
description="Fügen Sie Ihren ersten Veranstaltungsort hinzu."
|
description={t('locations.noLocationsDescription')}
|
||||||
actionLabel="Neuer Ort"
|
actionLabel={t('locations.newLocationLabel')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Orte ({locations.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('locations.allTitle', { count: locations.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Adresse</th>
|
{t('common.name')}
|
||||||
<th className="p-3 text-left font-medium">Raum</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.address')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.room')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('list.capacity')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{locations.map((loc: Record<string, unknown>) => (
|
{locations.map((loc: Record<string, unknown>) => (
|
||||||
<tr key={String(loc.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(loc.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">{String(loc.name)}</td>
|
<td className="p-3 font-medium">{String(loc.name)}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{[loc.street, loc.postal_code, loc.city]
|
{[loc.street, loc.postal_code, loc.city]
|
||||||
@@ -74,7 +86,9 @@ export default async function LocationsPage({ params }: PageProps) {
|
|||||||
.join(', ') || '—'}
|
.join(', ') || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(loc.room ?? '—')}</td>
|
<td className="p-3">{String(loc.room ?? '—')}</td>
|
||||||
<td className="p-3 text-right">{String(loc.capacity ?? '—')}</td>
|
<td className="p-3 text-right">
|
||||||
|
{String(loc.capacity ?? '—')}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { CreateCourseForm } from '@kit/course-management/components';
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface Props { params: Promise<{ account: string }> }
|
import { CreateCourseForm } from '@kit/course-management/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NewCoursePage({ params }: Props) {
|
export default async function NewCoursePage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('pages.newCourseTitle')}
|
||||||
|
description={t('pages.newCourseDescription')}
|
||||||
|
>
|
||||||
<CreateCourseForm accountId={acct.id} account={account} />
|
<CreateCourseForm accountId={acct.id} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
GraduationCap,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import {
|
||||||
import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
|
COURSE_STATUS_VARIANT,
|
||||||
|
COURSE_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -26,6 +38,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -39,84 +52,125 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
const [courses, stats] = await Promise.all([
|
const [courses, stats] = await Promise.all([
|
||||||
api.listCourses(acct.id, { page, pageSize: PAGE_SIZE }),
|
api.courses.list(acct.id, {
|
||||||
api.getStatistics(acct.id),
|
search: search.q as string,
|
||||||
|
status: search.status as string,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
api.statistics.getQuickStats(acct.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
|
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Kurse">
|
<CmsPageShell account={account} title={t('pages.coursesTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Kursangebot verwalten
|
{t('pages.coursesDescription')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link href={`/home/${account}/courses/new`}>
|
<Button data-test="courses-new-btn" asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/courses/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neuer Kurs
|
{t('nav.newCourse')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Gesamt"
|
title={t('stats.total')}
|
||||||
value={stats.totalCourses}
|
value={stats.totalCourses}
|
||||||
icon={<GraduationCap className="h-5 w-5" />}
|
icon={<GraduationCap className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Aktiv"
|
title={t('stats.active')}
|
||||||
value={stats.openCourses}
|
value={stats.openCourses}
|
||||||
icon={<Calendar className="h-5 w-5" />}
|
icon={<Calendar className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Abgeschlossen"
|
title={t('stats.completed')}
|
||||||
value={stats.completedCourses}
|
value={stats.completedCourses}
|
||||||
icon={<GraduationCap className="h-5 w-5" />}
|
icon={<GraduationCap className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Teilnehmer"
|
title={t('stats.participants')}
|
||||||
value={stats.totalParticipants}
|
value={stats.totalParticipants}
|
||||||
icon={<Users className="h-5 w-5" />}
|
icon={<Users className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<ListToolbar
|
||||||
|
searchPlaceholder={t('list.searchPlaceholder')}
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
param: 'status',
|
||||||
|
label: t('common.status'),
|
||||||
|
options: [
|
||||||
|
{ value: '', label: t('common.all') },
|
||||||
|
{ value: 'planned', label: t('status.planned') },
|
||||||
|
{ value: 'open', label: t('status.open') },
|
||||||
|
{ value: 'running', label: t('status.running') },
|
||||||
|
{ value: 'completed', label: t('status.completed') },
|
||||||
|
{ value: 'cancelled', label: t('status.cancelled') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table or Empty State */}
|
{/* Table or Empty State */}
|
||||||
{courses.data.length === 0 ? (
|
{courses.data.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<GraduationCap className="h-8 w-8" />}
|
icon={<GraduationCap className="h-8 w-8" />}
|
||||||
title="Keine Kurse vorhanden"
|
title={t('list.noCourses')}
|
||||||
description="Erstellen Sie Ihren ersten Kurs, um loszulegen."
|
description={t('list.createFirst')}
|
||||||
actionLabel="Neuer Kurs"
|
actionLabel={t('nav.newCourse')}
|
||||||
actionHref={`/home/${account}/courses/new`}
|
actionHref={`/home/${account}/courses/new`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Kurse ({courses.total})</CardTitle>
|
<CardTitle>{t('list.title', { count: courses.total })}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Kursnr.</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
{t('list.courseNumber')}
|
||||||
<th className="p-3 text-left font-medium">Beginn</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Ende</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
{t('list.courseName')}
|
||||||
<th className="p-3 text-right font-medium">Kapazität</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Gebühr</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.startDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.endDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('list.status')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('list.capacity')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('list.fee')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{courses.data.map((course: Record<string, unknown>) => (
|
{courses.data.map((course: Record<string, unknown>) => (
|
||||||
<tr key={String(course.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(course.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-mono text-xs">
|
<td className="p-3 font-mono text-xs">
|
||||||
{String(course.course_number ?? '—')}
|
{String(course.course_number ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
@@ -129,20 +183,22 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{course.start_date
|
{formatDate(course.start_date as string)}
|
||||||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{course.end_date
|
{formatDate(course.end_date as string)}
|
||||||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'}
|
variant={
|
||||||
|
COURSE_STATUS_VARIANT[String(course.status)] ??
|
||||||
|
'secondary'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)}
|
{t(
|
||||||
|
COURSE_STATUS_LABEL_KEYS[String(course.status)] ??
|
||||||
|
String(course.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
@@ -152,7 +208,7 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{course.fee != null
|
{course.fee != null
|
||||||
? `${Number(course.fee).toFixed(2)} €`
|
? formatCurrencyAmount(course.fee as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -164,34 +220,39 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t px-2 py-4">
|
<div className="flex items-center justify-between border-t px-2 py-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Seite {page} von {totalPages} ({courses.total} Einträge)
|
{t('common.page')} {page} {t('common.of')} {totalPages} (
|
||||||
|
{courses.total} {t('common.entries')})
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{page > 1 ? (
|
{page > 1 ? (
|
||||||
<Link href={`/home/${account}/courses?page=${page - 1}`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link
|
||||||
|
href={`/home/${account}/courses?page=${page - 1}`}
|
||||||
|
>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
Zurück
|
{t('common.previous')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
Zurück
|
{t('common.previous')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{page < totalPages ? (
|
{page < totalPages ? (
|
||||||
<Link href={`/home/${account}/courses?page=${page + 1}`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link
|
||||||
Weiter
|
href={`/home/${account}/courses?page=${page + 1}`}
|
||||||
|
>
|
||||||
|
{t('common.next')}
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Button variant="outline" size="sm" disabled>
|
||||||
Weiter
|
{t('common.next')}
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
|
import {
|
||||||
|
GraduationCap,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
|
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,27 +23,48 @@ interface PageProps {
|
|||||||
export default async function CourseStatisticsPage({ params }: PageProps) {
|
export default async function CourseStatisticsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const stats = await api.getStatistics(acct.id);
|
const stats = await api.statistics.getQuickStats(acct.id);
|
||||||
|
|
||||||
const statusChartData = [
|
const statusChartData = [
|
||||||
{ name: 'Aktiv', value: stats.openCourses },
|
{ name: t('stats.active'), value: stats.openCourses },
|
||||||
{ name: 'Abgeschlossen', value: stats.completedCourses },
|
{ name: t('stats.completed'), value: stats.completedCourses },
|
||||||
{ name: 'Gesamt', value: stats.totalCourses },
|
{ name: t('stats.total'), value: stats.totalCourses },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Kurs-Statistiken">
|
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatsCard title="Kurse gesamt" value={stats.totalCourses} icon={<GraduationCap className="h-5 w-5" />} />
|
<StatsCard
|
||||||
<StatsCard title="Aktive Kurse" value={stats.openCourses} icon={<Calendar className="h-5 w-5" />} />
|
title={t('stats.totalCourses')}
|
||||||
<StatsCard title="Teilnehmer" value={stats.totalParticipants} icon={<Users className="h-5 w-5" />} />
|
value={stats.totalCourses}
|
||||||
<StatsCard title="Abgeschlossen" value={stats.completedCourses} icon={<TrendingUp className="h-5 w-5" />} />
|
icon={<GraduationCap className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title={t('stats.activeCourses')}
|
||||||
|
value={stats.openCourses}
|
||||||
|
icon={<Calendar className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title={t('stats.participants')}
|
||||||
|
value={stats.totalParticipants}
|
||||||
|
icon={<Users className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title={t('stats.completed')}
|
||||||
|
value={stats.completedCourses}
|
||||||
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
@@ -45,7 +72,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<BarChart3 className="h-5 w-5" />
|
<BarChart3 className="h-5 w-5" />
|
||||||
Kursauslastung
|
{t('stats.utilization')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -57,7 +84,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-5 w-5" />
|
<TrendingUp className="h-5 w-5" />
|
||||||
Verteilung
|
{t('stats.distribution')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -68,12 +68,13 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
<select
|
<select
|
||||||
id="documentType"
|
id="documentType"
|
||||||
name="documentType"
|
name="documentType"
|
||||||
|
data-test="document-type-select"
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedType(e.target.value);
|
setSelectedType(e.target.value);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}}
|
}}
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<option value="member-card">Mitgliedsausweis</option>
|
<option value="member-card">Mitgliedsausweis</option>
|
||||||
<option value="invoice">Rechnung</option>
|
<option value="invoice">Rechnung</option>
|
||||||
@@ -92,7 +93,8 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
<p className="font-medium">Demnächst verfügbar</p>
|
<p className="font-medium">Demnächst verfügbar</p>
|
||||||
<p className="mt-1 text-amber-700 dark:text-amber-300">
|
<p className="mt-1 text-amber-700 dark:text-amber-300">
|
||||||
Die Generierung von “{DOCUMENT_LABELS[selectedType]}”
|
Die Generierung von “{DOCUMENT_LABELS[selectedType]}”
|
||||||
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
|
befindet sich noch in Entwicklung und wird in Kürze verfügbar
|
||||||
|
sein.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +120,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
id="format"
|
id="format"
|
||||||
name="format"
|
name="format"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="A4">A4</option>
|
<option value="A4">A4</option>
|
||||||
<option value="A5">A5</option>
|
<option value="A5">A5</option>
|
||||||
@@ -131,7 +133,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
id="orientation"
|
id="orientation"
|
||||||
name="orientation"
|
name="orientation"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="portrait">Hochformat</option>
|
<option value="portrait">Hochformat</option>
|
||||||
<option value="landscape">Querformat</option>
|
<option value="landscape">Querformat</option>
|
||||||
@@ -140,7 +142,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
|
<div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm">
|
||||||
<p>
|
<p>
|
||||||
<strong>Hinweis:</strong>{' '}
|
<strong>Hinweis:</strong>{' '}
|
||||||
{selectedType === 'member-card'
|
{selectedType === 'member-card'
|
||||||
@@ -189,7 +191,11 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending || isComingSoon}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
data-test="document-generate-btn"
|
||||||
|
disabled={isPending || isComingSoon}
|
||||||
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -211,11 +217,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
|
|||||||
* Trigger a browser download from a base64 string.
|
* Trigger a browser download from a base64 string.
|
||||||
* Uses an anchor element with the download attribute set to the full filename.
|
* Uses an anchor element with the download attribute set to the full filename.
|
||||||
*/
|
*/
|
||||||
function downloadFile(
|
function downloadFile(base64Data: string, mimeType: string, fileName: string) {
|
||||||
base64Data: string,
|
|
||||||
mimeType: string,
|
|
||||||
fileName: string,
|
|
||||||
) {
|
|
||||||
const byteCharacters = atob(base64Data);
|
const byteCharacters = atob(base64Data);
|
||||||
const byteNumbers = new Array(byteCharacters.length);
|
const byteNumbers = new Array(byteCharacters.length);
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
|
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
export type GenerateDocumentInput = {
|
export type GenerateDocumentInput = {
|
||||||
accountSlug: string;
|
accountSlug: string;
|
||||||
@@ -55,7 +60,11 @@ export async function generateDocumentAction(
|
|||||||
return { success: false, error: 'Unbekannter Dokumenttyp.' };
|
return { success: false, error: 'Unbekannter Dokumenttyp.' };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Document generation error:', err);
|
const logger = await getLogger();
|
||||||
|
logger.error(
|
||||||
|
{ error: err, context: 'document-generation' },
|
||||||
|
'Document generation error',
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
|
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
|
||||||
@@ -73,31 +82,42 @@ const LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function fmtDate(d: string | null): string {
|
function fmtDate(d: string | null): string {
|
||||||
if (!d) return '–';
|
return formatDate(d);
|
||||||
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// Member Card PDF — premium design with color accent bar, structured layout
|
// Member Card PDF — premium design with color accent bar, structured layout
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
async function generateMemberCards(
|
async function generateMemberCards(
|
||||||
client: ReturnType<typeof getSupabaseServerClient>,
|
client: SupabaseClient<Database>,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
accountName: string,
|
accountName: string,
|
||||||
input: GenerateDocumentInput,
|
input: GenerateDocumentInput,
|
||||||
): Promise<GenerateDocumentResult> {
|
): Promise<GenerateDocumentResult> {
|
||||||
const { data: members, error } = await client
|
const { data: members, error } = await client
|
||||||
.from('members')
|
.from('members')
|
||||||
.select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email')
|
.select(
|
||||||
|
'id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email',
|
||||||
|
)
|
||||||
.eq('account_id', accountId)
|
.eq('account_id', accountId)
|
||||||
.eq('status', 'active')
|
.eq('status', 'active')
|
||||||
.order('last_name');
|
.order('last_name');
|
||||||
|
|
||||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
if (!members?.length)
|
||||||
|
return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||||
|
|
||||||
const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } =
|
const {
|
||||||
await import('@react-pdf/renderer');
|
Document,
|
||||||
|
Page,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
renderToBuffer,
|
||||||
|
Svg: _Svg,
|
||||||
|
Rect: _Rect,
|
||||||
|
Circle: _Circle,
|
||||||
|
} = await import('@react-pdf/renderer');
|
||||||
|
|
||||||
// — Brand colors (configurable later via account settings) —
|
// — Brand colors (configurable later via account settings) —
|
||||||
const PRIMARY = '#1e40af';
|
const PRIMARY = '#1e40af';
|
||||||
@@ -107,7 +127,13 @@ async function generateMemberCards(
|
|||||||
const LIGHT_GRAY = '#f1f5f9';
|
const LIGHT_GRAY = '#f1f5f9';
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
|
page: {
|
||||||
|
padding: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 16,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Card shell ──
|
// ── Card shell ──
|
||||||
card: {
|
card: {
|
||||||
@@ -138,10 +164,22 @@ async function generateMemberCards(
|
|||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
|
badgeText: {
|
||||||
|
fontSize: 6,
|
||||||
|
color: PRIMARY,
|
||||||
|
fontFamily: 'Helvetica-Bold',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
},
|
||||||
|
|
||||||
// ── Main content ──
|
// ── Main content ──
|
||||||
body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 },
|
body: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
// Photo column
|
// Photo column
|
||||||
photoCol: { width: 64, alignItems: 'center' },
|
photoCol: { width: 64, alignItems: 'center' },
|
||||||
@@ -165,11 +203,22 @@ async function generateMemberCards(
|
|||||||
|
|
||||||
// Info column
|
// Info column
|
||||||
infoCol: { flex: 1, justifyContent: 'center' },
|
infoCol: { flex: 1, justifyContent: 'center' },
|
||||||
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
|
memberName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Helvetica-Bold',
|
||||||
|
color: DARK,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
|
||||||
fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
||||||
field: { width: '48%', marginBottom: 5 },
|
field: { width: '48%', marginBottom: 5 },
|
||||||
fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 },
|
fieldLabel: {
|
||||||
|
fontSize: 6,
|
||||||
|
color: GRAY,
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
|
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
|
||||||
|
|
||||||
// ── Footer ──
|
// ── Footer ──
|
||||||
@@ -184,10 +233,16 @@ async function generateMemberCards(
|
|||||||
},
|
},
|
||||||
footerLeft: { fontSize: 6, color: GRAY },
|
footerLeft: { fontSize: 6, color: GRAY },
|
||||||
footerRight: { fontSize: 6, color: GRAY },
|
footerRight: { fontSize: 6, color: GRAY },
|
||||||
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
|
validDot: {
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
backgroundColor: '#22c55e',
|
||||||
|
marginRight: 3,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('de-DE');
|
const today = formatDate(new Date());
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const cardsPerPage = 4;
|
const cardsPerPage = 4;
|
||||||
const pages: React.ReactElement[] = [];
|
const pages: React.ReactElement[] = [];
|
||||||
@@ -198,52 +253,122 @@ async function generateMemberCards(
|
|||||||
pages.push(
|
pages.push(
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Page,
|
Page,
|
||||||
{ key: `p${i}`, size: input.format === 'letter' ? 'LETTER' : (input.format.toUpperCase() as 'A4'|'A5'), orientation: input.orientation, style: s.page },
|
{
|
||||||
...batch.map((m) =>
|
key: `p${i}`,
|
||||||
React.createElement(View, { key: m.id, style: s.card },
|
size:
|
||||||
|
input.format === 'letter'
|
||||||
|
? 'LETTER'
|
||||||
|
: (input.format.toUpperCase() as 'A4' | 'A5'),
|
||||||
|
orientation: input.orientation,
|
||||||
|
style: s.page,
|
||||||
|
},
|
||||||
|
...batch.map((memberItem) =>
|
||||||
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ key: memberItem.id, style: s.card },
|
||||||
// Accent bar
|
// Accent bar
|
||||||
React.createElement(View, { style: s.accentBar }),
|
React.createElement(View, { style: s.accentBar }),
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
React.createElement(View, { style: s.header },
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ style: s.header },
|
||||||
React.createElement(Text, { style: s.clubName }, accountName),
|
React.createElement(Text, { style: s.clubName }, accountName),
|
||||||
React.createElement(View, { style: s.badge },
|
React.createElement(
|
||||||
React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'),
|
View,
|
||||||
|
{ style: s.badge },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.badgeText },
|
||||||
|
'Mitgliedsausweis',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Body: photo + info
|
// Body: photo + info
|
||||||
React.createElement(View, { style: s.body },
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ style: s.body },
|
||||||
// Photo column
|
// Photo column
|
||||||
React.createElement(View, { style: s.photoCol },
|
React.createElement(
|
||||||
React.createElement(View, { style: s.photoFrame },
|
View,
|
||||||
|
{ style: s.photoCol },
|
||||||
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ style: s.photoFrame },
|
||||||
React.createElement(Text, { style: s.photoIcon }, '👤'),
|
React.createElement(Text, { style: s.photoIcon }, '👤'),
|
||||||
),
|
),
|
||||||
React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? '–'}`),
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.memberNumber },
|
||||||
|
`Nr. ${memberItem.member_number ?? '–'}`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info column
|
// Info column
|
||||||
React.createElement(View, { style: s.infoCol },
|
React.createElement(
|
||||||
React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`),
|
View,
|
||||||
React.createElement(View, { style: s.fieldGroup },
|
{ style: s.infoCol },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.memberName },
|
||||||
|
`${memberItem.first_name} ${memberItem.last_name}`,
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ style: s.fieldGroup },
|
||||||
// Entry date
|
// Entry date
|
||||||
React.createElement(View, { style: s.field },
|
React.createElement(
|
||||||
React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'),
|
View,
|
||||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)),
|
{ style: s.field },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.fieldLabel },
|
||||||
|
'Mitglied seit',
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.fieldValue },
|
||||||
|
fmtDate(memberItem.entry_date),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Date of birth
|
// Date of birth
|
||||||
React.createElement(View, { style: s.field },
|
React.createElement(
|
||||||
React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'),
|
View,
|
||||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)),
|
{ style: s.field },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.fieldLabel },
|
||||||
|
'Geb.-Datum',
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.fieldValue },
|
||||||
|
fmtDate(memberItem.date_of_birth),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Address
|
// Address
|
||||||
React.createElement(View, { style: { ...s.field, width: '100%' } },
|
React.createElement(
|
||||||
React.createElement(Text, { style: s.fieldLabel }, 'Adresse'),
|
View,
|
||||||
React.createElement(Text, { style: s.fieldValue },
|
{ style: { ...s.field, width: '100%' } },
|
||||||
[m.street, m.house_number].filter(Boolean).join(' ') || '–',
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.fieldLabel },
|
||||||
|
'Adresse',
|
||||||
),
|
),
|
||||||
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
|
React.createElement(
|
||||||
[m.postal_code, m.city].filter(Boolean).join(' ') || '',
|
Text,
|
||||||
|
{ style: s.fieldValue },
|
||||||
|
[memberItem.street, memberItem.house_number]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || '–',
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: { ...s.fieldValue, marginTop: 1 } },
|
||||||
|
[memberItem.postal_code, memberItem.city]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || '',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -251,12 +376,24 @@ async function generateMemberCards(
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
React.createElement(View, { style: s.footer },
|
React.createElement(
|
||||||
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
|
View,
|
||||||
|
{ style: s.footer },
|
||||||
|
React.createElement(
|
||||||
|
View,
|
||||||
|
{ style: { flexDirection: 'row', alignItems: 'center' } },
|
||||||
React.createElement(View, { style: s.validDot }),
|
React.createElement(View, { style: s.validDot }),
|
||||||
React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`),
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.footerLeft },
|
||||||
|
`Gültig ${year}/${year + 1}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ style: s.footerRight },
|
||||||
|
`Ausgestellt ${today}`,
|
||||||
),
|
),
|
||||||
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -279,25 +416,38 @@ async function generateMemberCards(
|
|||||||
// Address Labels (HTML — Avery L7163)
|
// Address Labels (HTML — Avery L7163)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
async function generateLabels(
|
async function generateLabels(
|
||||||
client: ReturnType<typeof getSupabaseServerClient>,
|
client: SupabaseClient<Database>,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
input: GenerateDocumentInput,
|
input: GenerateDocumentInput,
|
||||||
): Promise<GenerateDocumentResult> {
|
): Promise<GenerateDocumentResult> {
|
||||||
const { data: members, error } = await client
|
const { data: members, error } = await client
|
||||||
.from('members')
|
.from('members')
|
||||||
.select('first_name, last_name, street, house_number, postal_code, city, salutation, title')
|
.select(
|
||||||
|
'first_name, last_name, street, house_number, postal_code, city, salutation, title',
|
||||||
|
)
|
||||||
.eq('account_id', accountId)
|
.eq('account_id', accountId)
|
||||||
.eq('status', 'active')
|
.eq('status', 'active')
|
||||||
.order('last_name');
|
.order('last_name');
|
||||||
|
|
||||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
if (!members?.length)
|
||||||
|
return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||||
|
|
||||||
const api = createDocumentGeneratorApi();
|
const api = createDocumentGeneratorApi();
|
||||||
const records = members.map((m) => ({
|
const records = members.map((record) => ({
|
||||||
line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '),
|
line1: [
|
||||||
line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined,
|
record.salutation,
|
||||||
line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined,
|
record.title,
|
||||||
|
record.first_name,
|
||||||
|
record.last_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
line2:
|
||||||
|
[record.street, record.house_number].filter(Boolean).join(' ') ||
|
||||||
|
undefined,
|
||||||
|
line3:
|
||||||
|
[record.postal_code, record.city].filter(Boolean).join(' ') || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
|
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
|
||||||
@@ -314,13 +464,15 @@ async function generateLabels(
|
|||||||
// Member Report (Excel)
|
// Member Report (Excel)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
async function generateMemberReport(
|
async function generateMemberReport(
|
||||||
client: ReturnType<typeof getSupabaseServerClient>,
|
client: SupabaseClient<Database>,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
input: GenerateDocumentInput,
|
input: GenerateDocumentInput,
|
||||||
): Promise<GenerateDocumentResult> {
|
): Promise<GenerateDocumentResult> {
|
||||||
const { data: members, error } = await client
|
const { data: members, error } = await client
|
||||||
.from('members')
|
.from('members')
|
||||||
.select('member_number, last_name, first_name, email, postal_code, city, status, entry_date')
|
.select(
|
||||||
|
'member_number, last_name, first_name, email, postal_code, city, status, entry_date',
|
||||||
|
)
|
||||||
.eq('account_id', accountId)
|
.eq('account_id', accountId)
|
||||||
.order('last_name');
|
.order('last_name');
|
||||||
|
|
||||||
@@ -346,27 +498,42 @@ async function generateMemberReport(
|
|||||||
|
|
||||||
const hdr = ws.getRow(1);
|
const hdr = ws.getRow(1);
|
||||||
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||||
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
|
hdr.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FF1E40AF' },
|
||||||
|
};
|
||||||
hdr.alignment = { vertical: 'middle', horizontal: 'center' };
|
hdr.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
hdr.height = 24;
|
hdr.height = 24;
|
||||||
|
|
||||||
const SL: Record<string, string> = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' };
|
const SL: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
resigned: 'Ausgetreten',
|
||||||
|
excluded: 'Ausgeschlossen',
|
||||||
|
};
|
||||||
|
|
||||||
for (const m of members) {
|
for (const member of members) {
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
nr: m.member_number ?? '',
|
nr: member.member_number ?? '',
|
||||||
name: m.last_name,
|
name: member.last_name,
|
||||||
vorname: m.first_name,
|
vorname: member.first_name,
|
||||||
email: m.email ?? '',
|
email: member.email ?? '',
|
||||||
plz: m.postal_code ?? '',
|
plz: member.postal_code ?? '',
|
||||||
ort: m.city ?? '',
|
ort: member.city ?? '',
|
||||||
status: SL[m.status] ?? m.status,
|
status: SL[member.status] ?? member.status,
|
||||||
eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '',
|
eintritt: member.entry_date ? formatDate(member.entry_date) : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.eachRow((row, n) => {
|
ws.eachRow((row, n) => {
|
||||||
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
|
if (n > 1 && n % 2 === 0)
|
||||||
|
row.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFF1F5F9' },
|
||||||
|
};
|
||||||
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
|
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,7 +546,8 @@ async function generateMemberReport(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
|
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
mimeType:
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
|
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -13,25 +14,16 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
import { GenerateDocumentForm } from '../_components/generate-document-form';
|
import { GenerateDocumentForm } from '../_components/generate-document-form';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
searchParams: Promise<{ type?: string }>;
|
searchParams: Promise<{ type?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOCUMENT_LABELS: Record<string, string> = {
|
|
||||||
'member-card': 'Mitgliedsausweis',
|
|
||||||
invoice: 'Rechnung',
|
|
||||||
labels: 'Etiketten',
|
|
||||||
report: 'Bericht',
|
|
||||||
letter: 'Brief',
|
|
||||||
certificate: 'Zertifikat',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function GenerateDocumentPage({
|
export default async function GenerateDocumentPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -39,6 +31,7 @@ export default async function GenerateDocumentPage({
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const { type } = await searchParams;
|
const { type } = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('documents');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -49,10 +42,13 @@ export default async function GenerateDocumentPage({
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const selectedType = type ?? 'member-card';
|
const selectedType = type ?? 'member-card';
|
||||||
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
|
// Resolve the label from translations; fall back to generic 'Document'
|
||||||
|
const selectedLabel =
|
||||||
|
(t.raw(`types.${selectedType}`) as string | undefined) ??
|
||||||
|
t('generate.document');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Dokument generieren">
|
<CmsPageShell account={account} title={t('generate.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div>
|
<div>
|
||||||
@@ -61,16 +57,16 @@ export default async function GenerateDocumentPage({
|
|||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Zurück zu Dokumente
|
{t('generate.backToDocuments')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="max-w-2xl">
|
<Card className="max-w-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{selectedLabel} generieren</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>
|
{t('generate.generateLabel', { label: selectedLabel })}
|
||||||
Wählen Sie den Dokumenttyp und die gewünschten Optionen.
|
</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription>{t('generate.chooseOptions')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -82,7 +78,7 @@ export default async function GenerateDocumentPage({
|
|||||||
|
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Link href={`/home/${account}/documents`}>
|
<Link href={`/home/${account}/documents`}>
|
||||||
<Button variant="outline">Abbrechen</Button>
|
<Button variant="outline">{t('generate.cancel')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Award,
|
Award,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -23,49 +24,31 @@ interface PageProps {
|
|||||||
const DOCUMENT_TYPES = [
|
const DOCUMENT_TYPES = [
|
||||||
{
|
{
|
||||||
id: 'member-card',
|
id: 'member-card',
|
||||||
title: 'Mitgliedsausweis',
|
|
||||||
description:
|
|
||||||
'Mitgliedsausweise mit Foto, Name und Mitgliedsnummer generieren.',
|
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
color: 'text-blue-600 bg-blue-50',
|
color: 'text-blue-600 bg-blue-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'invoice',
|
id: 'invoice',
|
||||||
title: 'Rechnung',
|
|
||||||
description:
|
|
||||||
'Professionelle Rechnungen im PDF-Format mit Logo und Positionen.',
|
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
color: 'text-green-600 bg-green-50',
|
color: 'text-green-600 bg-green-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'labels',
|
id: 'labels',
|
||||||
title: 'Etiketten',
|
|
||||||
description:
|
|
||||||
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
|
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
color: 'text-orange-600 bg-orange-50',
|
color: 'text-orange-600 bg-orange-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'report',
|
id: 'report',
|
||||||
title: 'Bericht',
|
|
||||||
description:
|
|
||||||
'Statistische Auswertungen und Berichte als PDF oder Excel.',
|
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
color: 'text-purple-600 bg-purple-50',
|
color: 'text-purple-600 bg-purple-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'letter',
|
id: 'letter',
|
||||||
title: 'Brief',
|
|
||||||
description:
|
|
||||||
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
|
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
color: 'text-rose-600 bg-rose-50',
|
color: 'text-rose-600 bg-rose-50',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'certificate',
|
id: 'certificate',
|
||||||
title: 'Zertifikat',
|
|
||||||
description:
|
|
||||||
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
|
|
||||||
icon: Award,
|
icon: Award,
|
||||||
color: 'text-amber-600 bg-amber-50',
|
color: 'text-amber-600 bg-amber-50',
|
||||||
},
|
},
|
||||||
@@ -74,6 +57,7 @@ const DOCUMENT_TYPES = [
|
|||||||
export default async function DocumentsPage({ params }: PageProps) {
|
export default async function DocumentsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('documents');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -84,12 +68,16 @@ export default async function DocumentsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('overview.title')}
|
||||||
|
description={t('overview.subtitle')}
|
||||||
|
>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<Link href={`/home/${account}/documents/templates`}>
|
<Link href={`/home/${account}/documents/templates`}>
|
||||||
<Button variant="outline">Vorlagen verwalten</Button>
|
<Button variant="outline">{t('overview.manageTemplates')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,18 +92,20 @@ export default async function DocumentsPage({ params }: PageProps) {
|
|||||||
<Icon className="h-6 w-6" />
|
<Icon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="text-base">{docType.title}</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
{t(`types.${docType.id}`)}
|
||||||
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 flex-col justify-between gap-4">
|
<CardContent className="flex flex-1 flex-col justify-between gap-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{docType.description}
|
{t(`typeDescriptions.${docType.id}`)}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/home/${account}/documents/generate?type=${docType.id}`}
|
href={`/home/${account}/documents/generate?type=${docType.id}`}
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
Erstellen
|
{t('overview.generate')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { FileText, Plus } from 'lucide-react';
|
import { FileText, Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -17,6 +16,7 @@ interface PageProps {
|
|||||||
export default async function DocumentTemplatesPage({ params }: PageProps) {
|
export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('documents');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -35,20 +35,17 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Dokumentvorlagen">
|
<CmsPageShell account={account} title={t('templates.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Dokumentvorlagen</h1>
|
<p className="text-muted-foreground">{t('templates.subtitle')}</p>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Vorlagen für Mitgliedsausweise, Rechnungen, Etiketten und mehr
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button>
|
<Button data-test="document-templates-new-btn">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neue Vorlage
|
{t('templates.newTemplate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,24 +53,30 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-8 w-8" />}
|
icon={<FileText className="h-8 w-8" />}
|
||||||
title="Keine Vorlagen vorhanden"
|
title={t('templates.noTemplates')}
|
||||||
description="Erstellen Sie Ihre erste Dokumentvorlage, um Mitgliedsausweise, Rechnungen und mehr zu generieren."
|
description={t('templates.createFirstLong')}
|
||||||
actionLabel="Neue Vorlage"
|
actionLabel={t('templates.newTemplate')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Vorlagen ({templates.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('templates.allTemplates', { count: templates.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Typ</th>
|
{t('templates.name')}
|
||||||
<th className="p-3 text-left font-medium">
|
</th>
|
||||||
Beschreibung
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('templates.type')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('templates.description')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -81,11 +84,11 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
|||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<tr
|
<tr
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-medium">{template.name}</td>
|
<td className="p-3 font-medium">{template.name}</td>
|
||||||
<td className="p-3">{template.type}</td>
|
<td className="p-3">{template.type}</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="text-muted-foreground p-3">
|
||||||
{template.description}
|
{template.description}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
42
apps/web/app/[locale]/home/[account]/error.tsx
Normal file
42
apps/web/app/[locale]/home/[account]/error.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
export default function AccountError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('common');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<div className="bg-destructive/10 mb-4 rounded-full p-4">
|
||||||
|
<AlertTriangle className="text-destructive h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">{t('error.title')}</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-md text-sm">
|
||||||
|
{t('error.description')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button onClick={reset}>{t('error.retry')}</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/home">{t('error.toDashboard')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { deleteEvent } from '@kit/event-management/actions/event-actions';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventId: string;
|
||||||
|
accountSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteEventButton({ eventId, accountSlug }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(deleteEvent, {
|
||||||
|
successMessage: 'Veranstaltung wurde abgesagt',
|
||||||
|
errorMessage: 'Fehler beim Absagen',
|
||||||
|
onSuccess: () => router.push(`/home/${accountSlug}/events`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="destructive" size="sm" disabled={isPending}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Absagen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Veranstaltung absagen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Die Veranstaltung wird auf den Status "Abgesagt" gesetzt.
|
||||||
|
Diese Aktion kann rückgängig gemacht werden.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => execute({ eventId })}>
|
||||||
|
Absagen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { CreateEventForm } from '@kit/event-management/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ account: string; eventId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditEventPage({ params }: PageProps) {
|
||||||
|
const { account, eventId } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('cms.events');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createEventManagementApi(client);
|
||||||
|
const event = await api.events.getById(eventId);
|
||||||
|
if (!event) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const e = event as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={`${String(e.name)} — ${t('editTitle')}`}
|
||||||
|
>
|
||||||
|
<CreateEventForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
eventId={eventId}
|
||||||
|
initialData={{
|
||||||
|
name: String(e.name ?? ''),
|
||||||
|
description: String(e.description ?? ''),
|
||||||
|
eventDate: String(e.event_date ?? ''),
|
||||||
|
eventTime: String(e.event_time ?? ''),
|
||||||
|
endDate: String(e.end_date ?? ''),
|
||||||
|
location: String(e.location ?? ''),
|
||||||
|
capacity: e.capacity != null ? Number(e.capacity) : undefined,
|
||||||
|
minAge: e.min_age != null ? Number(e.min_age) : undefined,
|
||||||
|
maxAge: e.max_age != null ? Number(e.max_age) : undefined,
|
||||||
|
fee: Number(e.fee ?? 0),
|
||||||
|
status: String(e.status ?? 'planned'),
|
||||||
|
registrationDeadline: String(e.registration_deadline ?? ''),
|
||||||
|
contactName: String(e.contact_name ?? ''),
|
||||||
|
contactEmail: String(e.contact_email ?? ''),
|
||||||
|
contactPhone: String(e.contact_phone ?? ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,71 +4,78 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
MapPin,
|
MapPin,
|
||||||
Users,
|
Users,
|
||||||
Euro,
|
|
||||||
Clock,
|
Clock,
|
||||||
|
Pencil,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import {
|
||||||
|
EVENT_STATUS_LABEL_KEYS,
|
||||||
|
EVENT_STATUS_VARIANT,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
|
import { DeleteEventButton } from './delete-event-button';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; eventId: string }>;
|
params: Promise<{ account: string; eventId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: 'Entwurf',
|
|
||||||
published: 'Veröffentlicht',
|
|
||||||
registration_open: 'Anmeldung offen',
|
|
||||||
registration_closed: 'Anmeldung geschlossen',
|
|
||||||
cancelled: 'Abgesagt',
|
|
||||||
completed: 'Abgeschlossen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'outline' | 'destructive'> = {
|
|
||||||
draft: 'secondary',
|
|
||||||
published: 'default',
|
|
||||||
registration_open: 'info',
|
|
||||||
registration_closed: 'outline',
|
|
||||||
cancelled: 'destructive',
|
|
||||||
completed: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function EventDetailPage({ params }: PageProps) {
|
export default async function EventDetailPage({ params }: PageProps) {
|
||||||
const { account, eventId } = await params;
|
const { account, eventId } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
|
const t = await getTranslations('cms.events');
|
||||||
|
|
||||||
const [event, registrations] = await Promise.all([
|
const [event, registrations] = await Promise.all([
|
||||||
api.getEvent(eventId),
|
api.events.getById(eventId),
|
||||||
api.getRegistrations(eventId),
|
api.registrations.list(eventId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!event) return <div>Veranstaltung nicht gefunden</div>;
|
if (!event) return <div>{t('notFound')}</div>;
|
||||||
|
|
||||||
const e = event as Record<string, unknown>;
|
const eventData = event as Record<string, unknown>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={String(e.name)}>
|
<CmsPageShell account={account} title={String(eventData.name)}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/home/${account}/events/${eventId}/edit`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
{t('edit')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteEventButton eventId={eventId} accountSlug={account} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{String(e.name)}</h1>
|
<Badge
|
||||||
<Badge variant={STATUS_VARIANT[String(e.status)] ?? 'secondary'} className="mt-1">
|
variant={
|
||||||
{STATUS_LABEL[String(e.status)] ?? String(e.status)}
|
EVENT_STATUS_VARIANT[String(eventData.status)] ?? 'secondary'
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
EVENT_STATUS_LABEL_KEYS[String(eventData.status)] ??
|
||||||
|
String(eventData.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Anmelden
|
{t('register')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,44 +83,47 @@ export default async function EventDetailPage({ params }: PageProps) {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<CalendarDays className="h-5 w-5 text-primary" />
|
<CalendarDays className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Datum</p>
|
<p className="text-muted-foreground text-xs">{t('date')}</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{e.event_date
|
{formatDate(eventData.event_date as string)}
|
||||||
? new Date(String(e.event_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Clock className="h-5 w-5 text-primary" />
|
<Clock className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Uhrzeit</p>
|
<p className="text-muted-foreground text-xs">{t('time')}</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{String(e.start_time ?? '—')} – {String(e.end_time ?? '—')}
|
{String(eventData.start_time ?? '—')} –{' '}
|
||||||
|
{String(eventData.end_time ?? '—')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<MapPin className="h-5 w-5 text-primary" />
|
<MapPin className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Ort</p>
|
<p className="text-muted-foreground text-xs">{t('location')}</p>
|
||||||
<p className="font-semibold">{String(e.location ?? '—')}</p>
|
<p className="font-semibold">
|
||||||
|
{String(eventData.location ?? '—')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="text-primary h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Anmeldungen</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('registrations')}
|
||||||
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{registrations.length} / {String(e.capacity ?? '∞')}
|
{registrations.length} / {String(eventData.capacity ?? '∞')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -121,14 +131,14 @@ export default async function EventDetailPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{e.description ? (
|
{eventData.description ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Beschreibung</CardTitle>
|
<CardTitle>{t('description')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||||
{String(e.description)}
|
{String(eventData.description)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -137,36 +147,50 @@ export default async function EventDetailPage({ params }: PageProps) {
|
|||||||
{/* Registrations Table */}
|
{/* Registrations Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Anmeldungen ({registrations.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('registrationsCount', { count: registrations.length })}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{registrations.length === 0 ? (
|
{registrations.length === 0 ? (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
Noch keine Anmeldungen
|
{t('noRegistrations')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
{t('name')}
|
||||||
<th className="p-3 text-left font-medium">Elternteil</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('parentName')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('date')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{registrations.map((reg: Record<string, unknown>) => (
|
{registrations.map((reg: Record<string, unknown>) => (
|
||||||
<tr key={String(reg.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(reg.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
{String(reg.last_name ?? '')}, {String(reg.first_name ?? '')}
|
{String(reg.last_name ?? '')},{' '}
|
||||||
|
{String(reg.first_name ?? '')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{String(reg.email ?? '—')}</td>
|
<td className="p-3">{String(reg.email ?? '—')}</td>
|
||||||
<td className="p-3">{String(reg.parent_name ?? '—')}</td>
|
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{reg.created_at
|
{String(reg.parent_name ?? '—')}
|
||||||
? new Date(String(reg.created_at)).toLocaleDateString('de-DE')
|
</td>
|
||||||
: '—'}
|
<td className="p-3">
|
||||||
|
{formatDate(reg.created_at as string)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Ticket, Plus } from 'lucide-react';
|
import { Ticket, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -29,15 +29,16 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
const passes = await api.listHolidayPasses(acct.id);
|
const passes = await api.holidayPasses.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('holidayPasses')}>
|
<CmsPageShell account={account} title={t('holidayPasses')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{t('holidayPasses')}</h1>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-muted-foreground">{t('holidayPassesDescription')}</p>
|
{t('holidayPassesDescription')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
@@ -55,39 +56,50 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('allHolidayPasses')} ({passes.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('allHolidayPasses')} ({passes.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">{t('year')}</th>
|
{t('name')}
|
||||||
<th className="p-3 text-right font-medium">{t('price')}</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">{t('validFrom')}</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">{t('validUntil')}</th>
|
{t('year')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('price')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('validFrom')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('validUntil')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{passes.map((pass: Record<string, unknown>) => (
|
{passes.map((pass: Record<string, unknown>) => (
|
||||||
<tr key={String(pass.id)} className="border-b hover:bg-muted/30">
|
<tr
|
||||||
|
key={String(pass.id)}
|
||||||
|
className="hover:bg-muted/30 border-b"
|
||||||
|
>
|
||||||
<td className="p-3 font-medium">{String(pass.name)}</td>
|
<td className="p-3 font-medium">{String(pass.name)}</td>
|
||||||
<td className="p-3">{String(pass.year ?? '—')}</td>
|
<td className="p-3">{String(pass.year ?? '—')}</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{pass.price != null
|
{pass.price != null
|
||||||
? `${Number(pass.price).toFixed(2)} €`
|
? formatCurrencyAmount(pass.price as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{pass.valid_from
|
{formatDate(pass.valid_from as string)}
|
||||||
? new Date(String(pass.valid_from)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{pass.valid_until
|
{formatDate(pass.valid_until as string)}
|
||||||
? new Date(String(pass.valid_until)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
import { CreateEventForm } from '@kit/event-management/components';
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface Props { params: Promise<{ account: string }> }
|
import { CreateEventForm } from '@kit/event-management/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NewEventPage({ params }: Props) {
|
export default async function NewEventPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const t = await getTranslations('cms.events');
|
const t = await getTranslations('cms.events');
|
||||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('newEvent')} description={t('newEventDescription')}>
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('newEvent')}
|
||||||
|
description={t('newEventDescription')}
|
||||||
|
>
|
||||||
<CreateEventForm accountId={acct.id} account={account} />
|
<CreateEventForm accountId={acct.id} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react';
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
MapPin,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import {
|
||||||
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
|
EVENT_STATUS_VARIANT,
|
||||||
|
EVENT_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -37,22 +47,24 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
const events = await api.listEvents(acct.id, { page });
|
const events = await api.events.list(acct.id, { page });
|
||||||
|
|
||||||
// Fetch registration counts for all events on this page
|
// Fetch registration counts for all events on this page
|
||||||
const eventIds = events.data.map((e: Record<string, unknown>) => String(e.id));
|
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
|
||||||
const registrationCounts = await api.getRegistrationCounts(eventIds);
|
String(eventItem.id),
|
||||||
|
);
|
||||||
|
const registrationCounts = await api.events.getRegistrationCounts(eventIds);
|
||||||
|
|
||||||
// Pre-compute stats before rendering
|
// Pre-compute stats before rendering
|
||||||
const uniqueLocationCount = new Set(
|
const uniqueLocationCount = new Set(
|
||||||
events.data
|
events.data
|
||||||
.map((e: Record<string, unknown>) => e.location)
|
.map((eventItem: Record<string, unknown>) => eventItem.location)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
).size;
|
).size;
|
||||||
|
|
||||||
const totalCapacity = events.data.reduce(
|
const totalCapacity = events.data.reduce(
|
||||||
(sum: number, e: Record<string, unknown>) =>
|
(sum: number, eventItem: Record<string, unknown>) =>
|
||||||
sum + (Number(e.capacity) || 0),
|
sum + (Number(eventItem.capacity) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,18 +74,15 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
<p className="text-muted-foreground">{t('description')}</p>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t('description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/home/${account}/events/new`}>
|
<Button data-test="events-new-btn" asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/events/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('newEvent')}
|
{t('newEvent')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -107,19 +116,33 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('allEvents')} ({events.total})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('allEvents')} ({events.total})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">{t('name')}</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
|
{t('name')}
|
||||||
<th className="p-3 text-left font-medium">{t('eventLocation')}</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">{t('capacity')}</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">{t('status')}</th>
|
{t('eventDate')}
|
||||||
<th className="p-3 text-right font-medium">{t('registrations')}</th>
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('eventLocation')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('capacity')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('statusLabel')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('registrations')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -130,7 +153,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={eventId}
|
key={eventId}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
<Link
|
<Link
|
||||||
@@ -141,9 +164,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{event.event_date
|
{formatDate(event.event_date as string)}
|
||||||
? new Date(String(event.event_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{String(event.location ?? '—')}
|
{String(event.location ?? '—')}
|
||||||
@@ -156,10 +177,14 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary'
|
EVENT_STATUS_VARIANT[String(event.status)] ??
|
||||||
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)}
|
{t(
|
||||||
|
EVENT_STATUS_LABEL_KEYS[String(event.status)] ??
|
||||||
|
String(event.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right font-medium">
|
<td className="p-3 text-right font-medium">
|
||||||
@@ -175,25 +200,32 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{events.totalPages > 1 && (
|
{events.totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{t('paginationPage', { page: events.page, totalPages: events.totalPages })}
|
{t('paginationPage', {
|
||||||
|
page: events.page,
|
||||||
|
totalPages: events.totalPages,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{events.page > 1 && (
|
{events.page > 1 && (
|
||||||
<Link href={`/home/${account}/events?page=${events.page - 1}`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link
|
||||||
|
href={`/home/${account}/events?page=${events.page - 1}`}
|
||||||
|
>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
{t('paginationPrevious')}
|
{t('paginationPrevious')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{events.page < events.totalPages && (
|
{events.page < events.totalPages && (
|
||||||
<Link href={`/home/${account}/events?page=${events.page + 1}`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link
|
||||||
|
href={`/home/${account}/events?page=${events.page + 1}`}
|
||||||
|
>
|
||||||
{t('paginationNext')}
|
{t('paginationNext')}
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
|
import { CalendarDays, ClipboardList, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import {
|
||||||
import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges';
|
EVENT_STATUS_VARIANT,
|
||||||
|
EVENT_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -33,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
const events = await api.listEvents(acct.id, { page: 1 });
|
const events = await api.events.list(acct.id, { page: 1 });
|
||||||
|
|
||||||
// Load registrations for each event in parallel
|
// Load registrations for each event in parallel
|
||||||
const eventsWithRegistrations = await Promise.all(
|
const eventsWithRegistrations = await Promise.all(
|
||||||
events.data.map(async (event: Record<string, unknown>) => {
|
events.data.map(async (event: Record<string, unknown>) => {
|
||||||
const registrations = await api.getRegistrations(String(event.id));
|
const registrations = await api.registrations.list(String(event.id));
|
||||||
return {
|
return {
|
||||||
id: String(event.id),
|
id: String(event.id),
|
||||||
name: String(event.name),
|
name: String(event.name),
|
||||||
@@ -63,10 +66,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{t('registrations')}</h1>
|
<p className="text-muted-foreground">{t('registrationsOverview')}</p>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t('registrationsOverview')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -105,20 +105,28 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
{t('event')}
|
{t('event')}
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">{t('eventDate')}</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">{t('status')}</th>
|
{t('eventDate')}
|
||||||
<th className="p-3 text-right font-medium">{t('capacity')}</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('statusLabel')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('capacity')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
{t('registrations')}
|
{t('registrations')}
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">{t('utilization')}</th>
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('utilization')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -133,7 +141,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
<Link
|
<Link
|
||||||
@@ -143,20 +151,18 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
{event.name}
|
{event.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">{formatDate(event.eventDate)}</td>
|
||||||
{event.eventDate
|
|
||||||
? new Date(event.eventDate).toLocaleDateString(
|
|
||||||
'de-DE',
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
|
EVENT_STATUS_VARIANT[event.status] ??
|
||||||
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{EVENT_STATUS_LABEL[event.status] ?? event.status}
|
{t(
|
||||||
|
EVENT_STATUS_LABEL_KEYS[event.status] ??
|
||||||
|
event.status,
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
interface DeleteConfirmButtonProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
isPending?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmButton({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
isPending,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-test="file-delete-btn"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Trash2 className="text-destructive h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird gelöscht...' : 'Löschen'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
import { uploadFile } from '@kit/module-builder/actions/file-actions';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface FileUploadDialogProps {
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
const ACCEPTED_TYPES = [
|
||||||
|
'image/*',
|
||||||
|
'application/pdf',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/csv',
|
||||||
|
'text/plain',
|
||||||
|
'application/zip',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadDialog({ accountId }: FileUploadDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
base64: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { execute, isPending } = useActionWithToast(uploadFile, {
|
||||||
|
successMessage: 'Datei hochgeladen',
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setError(null);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setError(null);
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setError('Die Datei darf maximal 10 MB groß sein.');
|
||||||
|
setSelectedFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
// Remove the data:...;base64, prefix
|
||||||
|
const base64 = result.split(',')[1] ?? '';
|
||||||
|
|
||||||
|
setSelectedFile({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
base64,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
setError('Fehler beim Lesen der Datei.');
|
||||||
|
setSelectedFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(() => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
execute({
|
||||||
|
accountId,
|
||||||
|
fileName: selectedFile.name,
|
||||||
|
fileType: selectedFile.type,
|
||||||
|
fileSize: selectedFile.size,
|
||||||
|
base64: selectedFile.base64,
|
||||||
|
});
|
||||||
|
}, [accountId, execute, selectedFile]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(isOpen: boolean) => {
|
||||||
|
if (!isPending) {
|
||||||
|
setOpen(isOpen);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPending],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="sm" data-test="file-upload-btn">
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Datei hochladen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent showCloseButton={!isPending}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Datei hochladen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Wählen Sie eine Datei aus (max. 10 MB).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-medium"
|
||||||
|
data-test="file-upload-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="bg-muted rounded-md p-3">
|
||||||
|
<p className="text-sm font-medium">{selectedFile.name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{selectedFile.type} · {formatFileSize(selectedFile.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile || isPending}
|
||||||
|
data-test="file-upload-submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
apps/web/app/[locale]/home/[account]/files/files-table.tsx
Normal file
208
apps/web/app/[locale]/home/[account]/files/files-table.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Download, FileIcon } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { deleteFile } from '@kit/module-builder/actions/file-actions';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
import { DeleteConfirmButton } from './delete-confirm-button';
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
id: string;
|
||||||
|
file_name: string;
|
||||||
|
original_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
created_at: string;
|
||||||
|
storage_path: string;
|
||||||
|
publicUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilesTableProps {
|
||||||
|
files: FileRecord[];
|
||||||
|
pagination: { total: number; page: number; pageSize: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-AT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeLabel(mimeType: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'application/pdf': 'PDF',
|
||||||
|
'image/jpeg': 'JPEG',
|
||||||
|
'image/png': 'PNG',
|
||||||
|
'image/gif': 'GIF',
|
||||||
|
'image/webp': 'WebP',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||||
|
'DOCX',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
|
||||||
|
'text/csv': 'CSV',
|
||||||
|
'text/plain': 'TXT',
|
||||||
|
'application/zip': 'ZIP',
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[mimeType] ?? mimeType.split('/').pop()?.toUpperCase() ?? 'Datei';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilesTable({ files, pagination }: FilesTableProps) {
|
||||||
|
const t = useTranslations('common');
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { total, page, pageSize } = pagination;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
|
||||||
|
deleteFile,
|
||||||
|
{
|
||||||
|
successMessage: 'Datei gelöscht',
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('page', String(newPage));
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dateien ({total})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<FileIcon className="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
|
<h3 className="text-lg font-semibold">Keine Dateien vorhanden</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
|
Laden Sie Ihre erste Datei hoch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Dateiname
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
Größe
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
Hochgeladen
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{files.map((file) => (
|
||||||
|
<tr key={file.id} className="hover:bg-muted/30 border-b">
|
||||||
|
<td className="max-w-[300px] truncate p-3 font-medium">
|
||||||
|
{file.original_name}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getMimeLabel(file.mime_type)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3 text-right">
|
||||||
|
{formatFileSize(file.file_size)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{formatDate(file.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<a
|
||||||
|
href={file.publicUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download={file.original_name}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-test="file-download-btn"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<DeleteConfirmButton
|
||||||
|
title={t('deleteFile')}
|
||||||
|
description={t('deleteFileConfirm')}
|
||||||
|
isPending={isDeleting}
|
||||||
|
onConfirm={() => executeDelete({ fileId: file.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Seite {page} von {totalPages} ({total} Einträge)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
data-test="files-prev-page"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
data-test="files-next-page"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/web/app/[locale]/home/[account]/files/page.tsx
Normal file
73
apps/web/app/[locale]/home/[account]/files/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
import { FileUploadDialog } from './file-upload-dialog';
|
||||||
|
import { FilesTable } from './files-table';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FilesPage({ params, searchParams }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('common');
|
||||||
|
const search = await searchParams;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createModuleBuilderApi(client);
|
||||||
|
const page = Number(search.page) || 1;
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const result = await api.files.listFiles(acct.id, {
|
||||||
|
search: search.q as string,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve public URLs for each file
|
||||||
|
const filesWithUrls = result.data.map((file) => ({
|
||||||
|
id: String(file.id),
|
||||||
|
file_name: String(file.file_name),
|
||||||
|
original_name: String(file.original_name),
|
||||||
|
mime_type: String(file.mime_type),
|
||||||
|
file_size: Number(file.file_size),
|
||||||
|
created_at: String(file.created_at),
|
||||||
|
storage_path: String(file.storage_path),
|
||||||
|
publicUrl: api.files.getPublicUrl(String(file.storage_path)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('filesTitle')}
|
||||||
|
description={t('filesSubtitle')}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<ListToolbar searchPlaceholder={t('filesSearch')} />
|
||||||
|
<FileUploadDialog accountId={acct.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilesTable
|
||||||
|
files={filesWithUrls}
|
||||||
|
pagination={{ total: result.total, page, pageSize }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,27 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, Send, CheckCircle } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
import { Badge } from '@kit/ui/badge';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import {
|
||||||
|
INVOICE_STATUS_VARIANT,
|
||||||
|
INVOICE_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
|
import { MarkPaidButton, SendInvoiceButton } from '../invoice-action-buttons';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; id: string }>;
|
params: Promise<{ account: string; id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<
|
|
||||||
string,
|
|
||||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
|
||||||
> = {
|
|
||||||
draft: 'secondary',
|
|
||||||
sent: 'default',
|
|
||||||
paid: 'info',
|
|
||||||
overdue: 'destructive',
|
|
||||||
cancelled: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: 'Entwurf',
|
|
||||||
sent: 'Versendet',
|
|
||||||
paid: 'Bezahlt',
|
|
||||||
overdue: 'Überfällig',
|
|
||||||
cancelled: 'Storniert',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: unknown) =>
|
const formatCurrency = (amount: unknown) =>
|
||||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||||
Number(amount),
|
Number(amount),
|
||||||
@@ -42,6 +29,7 @@ const formatCurrency = (amount: unknown) =>
|
|||||||
|
|
||||||
export default async function InvoiceDetailPage({ params }: PageProps) {
|
export default async function InvoiceDetailPage({ params }: PageProps) {
|
||||||
const { account, id } = await params;
|
const { account, id } = await params;
|
||||||
|
const t = await getTranslations('finance');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
@@ -55,13 +43,13 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
const api = createFinanceApi(client);
|
const api = createFinanceApi(client);
|
||||||
const invoice = await api.getInvoiceWithItems(id);
|
const invoice = await api.getInvoiceWithItems(id);
|
||||||
|
|
||||||
if (!invoice) return <div>Rechnung nicht gefunden</div>;
|
if (!invoice) return <AccountNotFound />;
|
||||||
|
|
||||||
const status = String(invoice.status);
|
const status = String(invoice.status);
|
||||||
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
|
const items = (invoice.items ?? []) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Rechnungsdetails">
|
<CmsPageShell account={account} title={t('invoices.detailTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div>
|
<div>
|
||||||
@@ -70,7 +58,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Zurück zu Rechnungen
|
{t('invoices.backToList')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,49 +66,43 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Rechnung {String(invoice.invoice_number ?? '')}
|
{t('invoices.invoiceLabel', {
|
||||||
|
number: String(invoice.invoice_number ?? ''),
|
||||||
|
})}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
<Badge variant={INVOICE_STATUS_VARIANT[status] ?? 'secondary'}>
|
||||||
{STATUS_LABEL[status] ?? status}
|
{t(INVOICE_STATUS_LABEL_KEYS[status] ?? status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Empfänger
|
{t('invoices.recipient')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{String(invoice.recipient_name ?? '—')}
|
{String(invoice.recipient_name ?? '—')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Rechnungsdatum
|
{t('invoices.issueDate')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{invoice.issue_date
|
{formatDate(invoice.issue_date)}
|
||||||
? new Date(
|
|
||||||
String(invoice.issue_date),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Fälligkeitsdatum
|
{t('invoices.dueDate')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{invoice.due_date
|
{formatDate(invoice.due_date)}
|
||||||
? new Date(String(invoice.due_date)).toLocaleDateString(
|
|
||||||
'de-DE',
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Gesamtbetrag
|
{t('invoices.amount')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{invoice.total_amount != null
|
{invoice.total_amount != null
|
||||||
@@ -133,16 +115,10 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
{status === 'draft' && (
|
{status === 'draft' && (
|
||||||
<Button>
|
<SendInvoiceButton invoiceId={id} accountId={acct.id} />
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{(status === 'sent' || status === 'overdue') && (
|
{(status === 'sent' || status === 'overdue') && (
|
||||||
<Button variant="outline">
|
<MarkPaidButton invoiceId={id} accountId={acct.id} />
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
|
||||||
Bezahlt markieren
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -151,33 +127,39 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Positionen ({items.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('invoiceForm.lineItems')} ({items.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||||
Keine Positionen vorhanden.
|
{t('invoices.noItems')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
Beschreibung
|
{t('invoiceForm.itemDescription')}
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Menge</th>
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
<th className="p-3 text-right font-medium">
|
{t('invoiceForm.quantity')}
|
||||||
Einzelpreis
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('invoices.unitPriceCol')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('invoices.totalCol')}
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(item.id)}
|
key={String(item.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{String(item.description ?? '—')}
|
{String(item.description ?? '—')}
|
||||||
@@ -199,9 +181,9 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t bg-muted/30">
|
<tr className="bg-muted/30 border-t">
|
||||||
<td colSpan={3} className="p-3 text-right font-medium">
|
<td colSpan={3} className="p-3 text-right font-medium">
|
||||||
Zwischensumme
|
{t('invoiceForm.subtotal')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{formatCurrency(invoice.subtotal ?? 0)}
|
{formatCurrency(invoice.subtotal ?? 0)}
|
||||||
@@ -209,7 +191,9 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="p-3 text-right font-medium">
|
<td colSpan={3} className="p-3 text-right font-medium">
|
||||||
MwSt. ({Number(invoice.tax_rate ?? 19)}%)
|
{t('invoiceForm.tax', {
|
||||||
|
rate: Number(invoice.tax_rate ?? 19),
|
||||||
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{formatCurrency(invoice.tax_amount ?? 0)}
|
{formatCurrency(invoice.tax_amount ?? 0)}
|
||||||
@@ -217,7 +201,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr className="border-t font-semibold">
|
<tr className="border-t font-semibold">
|
||||||
<td colSpan={3} className="p-3 text-right">
|
<td colSpan={3} className="p-3 text-right">
|
||||||
Gesamtbetrag
|
{t('invoiceForm.total')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{formatCurrency(invoice.total_amount ?? 0)}
|
{formatCurrency(invoice.total_amount ?? 0)}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CheckCircle, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
interface SendInvoiceButtonProps {
|
||||||
|
invoiceId: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendInvoiceButton({
|
||||||
|
invoiceId,
|
||||||
|
accountId,
|
||||||
|
}: SendInvoiceButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/finance/invoices/${invoiceId}/send`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send invoice:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button disabled={isPending}>
|
||||||
|
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Senden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Rechnung senden?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Diese Aktion kann nicht rückgängig gemacht werden. Die Rechnung wird
|
||||||
|
an den Empfänger gesendet und der Status auf „Versendet" gesetzt.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleSend}>
|
||||||
|
{isPending ? 'Wird gesendet...' : 'Senden'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkPaidButtonProps {
|
||||||
|
invoiceId: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkPaidButton({ invoiceId, accountId }: MarkPaidButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleMarkPaid = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/finance/invoices/${invoiceId}/mark-paid`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark invoice as paid:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" disabled={isPending}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Bezahlt markieren
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Als bezahlt markieren?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Der Status der Rechnung wird auf „Bezahlt" gesetzt. Diese Aktion
|
||||||
|
bestätigt den Zahlungseingang.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleMarkPaid}>
|
||||||
|
{isPending ? 'Wird gespeichert...' : 'Bestätigen'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,32 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { CreateInvoiceForm } from '@kit/finance/components';
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface Props { params: Promise<{ account: string }> }
|
import { CreateInvoiceForm } from '@kit/finance/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NewInvoicePage({ params }: Props) {
|
export default async function NewInvoicePage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('finance');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('invoices.newInvoice')}
|
||||||
|
description={t('invoices.newInvoiceDesc')}
|
||||||
|
>
|
||||||
<CreateInvoiceForm accountId={acct.id} account={account} />
|
<CreateInvoiceForm accountId={acct.id} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { FileText, Plus } from 'lucide-react';
|
import { FileText, Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
import {
|
import {
|
||||||
INVOICE_STATUS_VARIANT,
|
INVOICE_STATUS_VARIANT,
|
||||||
INVOICE_STATUS_LABEL,
|
INVOICE_STATUS_LABEL_KEYS,
|
||||||
} from '~/lib/status-badges';
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -29,6 +30,7 @@ const formatCurrency = (amount: unknown) =>
|
|||||||
export default async function InvoicesPage({ params }: PageProps) {
|
export default async function InvoicesPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('finance');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -39,51 +41,65 @@ export default async function InvoicesPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createFinanceApi(client);
|
const api = createFinanceApi(client);
|
||||||
const invoices = await api.listInvoices(acct.id);
|
const invoicesResult = await api.listInvoices(acct.id);
|
||||||
|
const invoices = invoicesResult.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Rechnungen">
|
<CmsPageShell account={account} title={t('invoices.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Rechnungen</h1>
|
|
||||||
<p className="text-muted-foreground">Rechnungen verwalten</p>
|
<p className="text-muted-foreground">Rechnungen verwalten</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/home/${account}/finance/invoices/new`}>
|
<Button asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/finance/invoices/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neue Rechnung
|
{t('invoices.newInvoice')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table or Empty State */}
|
{/* Table or Empty State */}
|
||||||
{invoices.length === 0 ? (
|
{invoices.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-8 w-8" />}
|
icon={<FileText className="h-8 w-8" />}
|
||||||
title="Keine Rechnungen vorhanden"
|
title={t('invoices.noInvoices')}
|
||||||
description="Erstellen Sie Ihre erste Rechnung."
|
description={t('invoices.createFirst')}
|
||||||
actionLabel="Neue Rechnung"
|
actionLabel={t('invoices.newInvoice')}
|
||||||
actionHref={`/home/${account}/finance/invoices/new`}
|
actionHref={`/home/${account}/finance/invoices/new`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Rechnungen ({invoices.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('invoices.title')} ({invoices.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Nr.</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Empfänger</th>
|
{t('invoices.invoiceNumber')}
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Fällig</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
{t('invoices.recipient')}
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('invoices.issueDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('invoices.dueDate')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('common.amount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -92,7 +108,7 @@ export default async function InvoicesPage({ params }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={String(invoice.id)}
|
key={String(invoice.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-mono text-xs">
|
<td className="p-3 font-mono text-xs">
|
||||||
<Link
|
<Link
|
||||||
@@ -106,18 +122,10 @@ export default async function InvoicesPage({ params }: PageProps) {
|
|||||||
{String(invoice.recipient_name ?? '—')}
|
{String(invoice.recipient_name ?? '—')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{invoice.issue_date
|
{formatDate(invoice.issue_date as string | null)}
|
||||||
? new Date(
|
|
||||||
String(invoice.issue_date),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{invoice.due_date
|
{formatDate(invoice.due_date as string | null)}
|
||||||
? new Date(
|
|
||||||
String(invoice.due_date),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{invoice.total_amount != null
|
{invoice.total_amount != null
|
||||||
@@ -130,7 +138,7 @@ export default async function InvoicesPage({ params }: PageProps) {
|
|||||||
INVOICE_STATUS_VARIANT[status] ?? 'secondary'
|
INVOICE_STATUS_VARIANT[status] ?? 'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{INVOICE_STATUS_LABEL[status] ?? status}
|
{t(INVOICE_STATUS_LABEL_KEYS[status] ?? status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,32 +1,61 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react';
|
import {
|
||||||
|
Landmark,
|
||||||
|
FileText,
|
||||||
|
Euro,
|
||||||
|
ArrowRight,
|
||||||
|
Plus,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
import {
|
import {
|
||||||
BATCH_STATUS_VARIANT,
|
BATCH_STATUS_VARIANT,
|
||||||
BATCH_STATUS_LABEL,
|
BATCH_STATUS_LABEL_KEYS,
|
||||||
INVOICE_STATUS_VARIANT,
|
INVOICE_STATUS_VARIANT,
|
||||||
INVOICE_STATUS_LABEL,
|
INVOICE_STATUS_LABEL_KEYS,
|
||||||
} from '~/lib/status-badges';
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function FinancePage({ params }: PageProps) {
|
function buildQuery(
|
||||||
|
base: Record<string, string | undefined>,
|
||||||
|
overrides: Record<string, string | number | undefined>,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries({ ...base, ...overrides })) {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FinancePage({ params, searchParams }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('finance');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -36,13 +65,20 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const q = typeof search.q === 'string' ? search.q : undefined;
|
||||||
|
const status = typeof search.status === 'string' ? search.status : undefined;
|
||||||
|
const page = Math.max(1, Number(search.page) || 1);
|
||||||
|
|
||||||
const api = createFinanceApi(client);
|
const api = createFinanceApi(client);
|
||||||
|
|
||||||
const [batches, invoices] = await Promise.all([
|
const [batchesResult, invoicesResult] = await Promise.all([
|
||||||
api.listBatches(acct.id),
|
api.listBatches(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }),
|
||||||
api.listInvoices(acct.id),
|
api.listInvoices(acct.id, { search: q, status, page, pageSize: PAGE_SIZE }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const batches = batchesResult.data;
|
||||||
|
const invoices = invoicesResult.data;
|
||||||
|
|
||||||
const openAmount = invoices
|
const openAmount = invoices
|
||||||
.filter(
|
.filter(
|
||||||
(inv: Record<string, unknown>) =>
|
(inv: Record<string, unknown>) =>
|
||||||
@@ -54,114 +90,154 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use the larger of the two totals for pagination
|
||||||
|
const totalPages = Math.max(
|
||||||
|
batchesResult.totalPages,
|
||||||
|
invoicesResult.totalPages,
|
||||||
|
);
|
||||||
|
const safePage = page;
|
||||||
|
|
||||||
|
const queryBase = { q, status };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Finanzen">
|
<CmsPageShell account={account} title={t('dashboard.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Finanzen</h1>
|
<p className="text-muted-foreground">{t('dashboard.subtitle')}</p>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
SEPA-Einzüge und Rechnungen
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/home/${account}/finance/invoices/new`}>
|
<Button variant="outline" asChild>
|
||||||
<Button variant="outline">
|
<Link href={`/home/${account}/finance/invoices/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neue Rechnung
|
{t('invoices.newInvoice')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
<Link href={`/home/${account}/finance/sepa/new`}>
|
<Button asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/finance/sepa/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neuer SEPA-Einzug
|
{t('nav.newBatch')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="SEPA-Einzüge"
|
title={t('dashboard.sepaBatches')}
|
||||||
value={batches.length}
|
value={batchesResult.total}
|
||||||
icon={<Landmark className="h-5 w-5" />}
|
icon={<Landmark className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Rechnungen"
|
title={t('invoices.title')}
|
||||||
value={invoices.length}
|
value={invoicesResult.total}
|
||||||
icon={<FileText className="h-5 w-5" />}
|
icon={<FileText className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Offener Betrag"
|
title={t('dashboard.openInvoices')}
|
||||||
value={`${openAmount.toFixed(2)} €`}
|
value={formatCurrencyAmount(openAmount)}
|
||||||
icon={<Euro className="h-5 w-5" />}
|
icon={<Euro className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<ListToolbar
|
||||||
|
searchPlaceholder={t('common.searchPlaceholder')}
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
param: 'status',
|
||||||
|
label: t('common.status'),
|
||||||
|
options: [
|
||||||
|
{ value: '', label: t('common.all') },
|
||||||
|
{ value: 'draft', label: t('status.draft') },
|
||||||
|
{ value: 'ready', label: t('sepa.newBatch') },
|
||||||
|
{ value: 'sent', label: t('status.sent') },
|
||||||
|
{ value: 'paid', label: t('status.paid') },
|
||||||
|
{ value: 'overdue', label: t('status.overdue') },
|
||||||
|
{ value: 'cancelled', label: t('status.cancelled') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* SEPA Batches */}
|
{/* SEPA Batches */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Letzte SEPA-Einzüge</CardTitle>
|
<CardTitle>
|
||||||
<Link href={`/home/${account}/finance/sepa`}>
|
{t('sepa.title')} ({batchesResult.total})
|
||||||
<Button variant="ghost" size="sm">
|
</CardTitle>
|
||||||
Alle anzeigen
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/home/${account}/finance/sepa`}>
|
||||||
|
{t('common.showAll')}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{batches.length === 0 ? (
|
{batches.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Landmark className="h-8 w-8" />}
|
icon={<Landmark className="h-8 w-8" />}
|
||||||
title="Keine SEPA-Einzüge"
|
title={t('sepa.noBatches')}
|
||||||
description="Erstellen Sie Ihren ersten SEPA-Einzug."
|
description={t('sepa.createFirst')}
|
||||||
actionLabel="Neuer SEPA-Einzug"
|
actionLabel={t('nav.newBatch')}
|
||||||
actionHref={`/home/${account}/finance/sepa/new`}
|
actionHref={`/home/${account}/finance/sepa/new`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Typ</th>
|
{t('common.status')}
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.type')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('common.amount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.date')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{batches.map((batch: Record<string, unknown>) => (
|
{batches.map((batch: Record<string, unknown>) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(batch.id)}
|
key={String(batch.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
BATCH_STATUS_VARIANT[String(batch.status)] ??
|
||||||
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)}
|
{t(
|
||||||
|
BATCH_STATUS_LABEL_KEYS[String(batch.status)] ??
|
||||||
|
String(batch.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{batch.batch_type === 'direct_debit'
|
{batch.batch_type === 'direct_debit'
|
||||||
? 'Lastschrift'
|
? t('sepa.directDebit')
|
||||||
: 'Überweisung'}
|
: t('sepa.creditTransfer')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{batch.total_amount != null
|
{batch.total_amount != null
|
||||||
? `${Number(batch.total_amount).toFixed(2)} €`
|
? formatCurrencyAmount(batch.total_amount as number)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{batch.execution_date
|
{formatDate(
|
||||||
? new Date(String(batch.execution_date)).toLocaleDateString('de-DE')
|
(batch.execution_date ?? batch.created_at) as
|
||||||
: batch.created_at
|
| string
|
||||||
? new Date(String(batch.created_at)).toLocaleDateString('de-DE')
|
| null,
|
||||||
: '—'}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -175,39 +251,49 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
{/* Invoices */}
|
{/* Invoices */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Letzte Rechnungen</CardTitle>
|
<CardTitle>
|
||||||
<Link href={`/home/${account}/finance/invoices`}>
|
{t('invoices.title')} ({invoicesResult.total})
|
||||||
<Button variant="ghost" size="sm">
|
</CardTitle>
|
||||||
Alle anzeigen
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/home/${account}/finance/invoices`}>
|
||||||
|
{t('common.showAll')}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{invoices.length === 0 ? (
|
{invoices.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-8 w-8" />}
|
icon={<FileText className="h-8 w-8" />}
|
||||||
title="Keine Rechnungen"
|
title={t('invoices.noInvoices')}
|
||||||
description="Erstellen Sie Ihre erste Rechnung."
|
description={t('invoices.createFirst')}
|
||||||
actionLabel="Neue Rechnung"
|
actionLabel={t('invoices.newInvoice')}
|
||||||
actionHref={`/home/${account}/finance/invoices/new`}
|
actionHref={`/home/${account}/finance/invoices/new`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Nr.</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Empfänger</th>
|
{t('invoices.invoiceNumber')}
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('invoices.recipient')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('common.amount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoices.map((invoice: Record<string, unknown>) => (
|
{invoices.map((invoice: Record<string, unknown>) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(invoice.id)}
|
key={String(invoice.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-mono text-xs">
|
<td className="p-3 font-mono text-xs">
|
||||||
<Link
|
<Link
|
||||||
@@ -222,7 +308,9 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
{invoice.total_amount != null
|
{invoice.total_amount != null
|
||||||
? `${Number(invoice.total_amount).toFixed(2)} €`
|
? formatCurrencyAmount(
|
||||||
|
invoice.total_amount as number,
|
||||||
|
)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
@@ -232,8 +320,11 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
'secondary'
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{INVOICE_STATUS_LABEL[String(invoice.status)] ??
|
{t(
|
||||||
String(invoice.status)}
|
INVOICE_STATUS_LABEL_KEYS[
|
||||||
|
String(invoice.status)
|
||||||
|
] ?? String(invoice.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -244,6 +335,60 @@ export default async function FinancePage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t('common.page')} {safePage} {t('common.of')} {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{safePage > 1 ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/finance${buildQuery(queryBase, { page: safePage - 1 })}`}
|
||||||
|
aria-label={t('common.previous')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
aria-label={t('common.previous')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="px-3 text-sm font-medium">
|
||||||
|
{safePage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{safePage < totalPages ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/finance${buildQuery(queryBase, { page: safePage + 1 })}`}
|
||||||
|
aria-label={t('common.next')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
aria-label={t('common.next')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
|
import { Euro, CreditCard, TrendingUp, ArrowRight } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { StatsCard } from '~/components/stats-card';
|
import { StatsCard } from '~/components/stats-card';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -24,6 +24,7 @@ const formatCurrency = (amount: number) =>
|
|||||||
|
|
||||||
export default async function PaymentsPage({ params }: PageProps) {
|
export default async function PaymentsPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('finance');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
@@ -36,10 +37,12 @@ export default async function PaymentsPage({ params }: PageProps) {
|
|||||||
|
|
||||||
const api = createFinanceApi(client);
|
const api = createFinanceApi(client);
|
||||||
|
|
||||||
const [batches, invoices] = await Promise.all([
|
const [batchesResult, invoicesResult] = await Promise.all([
|
||||||
api.listBatches(acct.id),
|
api.listBatches(acct.id),
|
||||||
api.listInvoices(acct.id),
|
api.listInvoices(acct.id),
|
||||||
]);
|
]);
|
||||||
|
const batches = batchesResult.data;
|
||||||
|
const invoices = invoicesResult.data;
|
||||||
|
|
||||||
const paidInvoices = invoices.filter(
|
const paidInvoices = invoices.filter(
|
||||||
(inv: Record<string, unknown>) => inv.status === 'paid',
|
(inv: Record<string, unknown>) => inv.status === 'paid',
|
||||||
@@ -77,41 +80,38 @@ export default async function PaymentsPage({ params }: PageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Zahlungen">
|
<CmsPageShell account={account} title={t('payments.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Zahlungsübersicht</h1>
|
<p className="text-muted-foreground">{t('payments.subtitle')}</p>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Zusammenfassung aller Zahlungen und offenen Beträge
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Bezahlt"
|
title={t('payments.statPaid')}
|
||||||
value={formatCurrency(paidTotal)}
|
value={formatCurrency(paidTotal)}
|
||||||
icon={<Euro className="h-5 w-5" />}
|
icon={<Euro className="h-5 w-5" />}
|
||||||
description={`${paidInvoices.length} Rechnungen`}
|
description={`${paidInvoices.length} ${t('payments.paidInvoices')}`}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Offen"
|
title={t('payments.statOpen')}
|
||||||
value={formatCurrency(openTotal)}
|
value={formatCurrency(openTotal)}
|
||||||
icon={<CreditCard className="h-5 w-5" />}
|
icon={<CreditCard className="h-5 w-5" />}
|
||||||
description={`${openInvoices.length} Rechnungen`}
|
description={`${openInvoices.length} ${t('invoices.title')}`}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Überfällig"
|
title={t('payments.statOverdue')}
|
||||||
value={formatCurrency(overdueTotal)}
|
value={formatCurrency(overdueTotal)}
|
||||||
icon={<TrendingUp className="h-5 w-5" />}
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
description={`${overdueInvoices.length} Rechnungen`}
|
description={`${overdueInvoices.length} ${t('invoices.title')}`}
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="SEPA-Einzüge"
|
title={t('payments.sepaBatches')}
|
||||||
value={formatCurrency(sepaTotal)}
|
value={formatCurrency(sepaTotal)}
|
||||||
icon={<Euro className="h-5 w-5" />}
|
icon={<Euro className="h-5 w-5" />}
|
||||||
description={`${batches.length} Einzüge`}
|
description={`${batches.length} ${t('payments.batchUnit')}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,45 +119,57 @@ export default async function PaymentsPage({ params }: PageProps) {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base">Offene Rechnungen</CardTitle>
|
<CardTitle className="text-base">
|
||||||
<Badge variant={openInvoices.length > 0 ? 'default' : 'secondary'}>
|
{t('payments.openInvoices')}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={openInvoices.length > 0 ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
{openInvoices.length}
|
{openInvoices.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="text-muted-foreground mb-4 text-sm">
|
||||||
{openInvoices.length > 0
|
{openInvoices.length > 0
|
||||||
? `${openInvoices.length} Rechnungen mit einem Gesamtbetrag von ${formatCurrency(openTotal)} sind offen.`
|
? t('payments.invoicesOpenSummary', {
|
||||||
: 'Keine offenen Rechnungen vorhanden.'}
|
count: openInvoices.length,
|
||||||
|
total: formatCurrency(openTotal),
|
||||||
|
})
|
||||||
|
: t('payments.noOpenInvoices')}
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/home/${account}/finance/invoices`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link href={`/home/${account}/finance/invoices`}>
|
||||||
Rechnungen anzeigen
|
{t('payments.viewInvoices')}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base">SEPA-Einzüge</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
{t('payments.sepaBatches')}
|
||||||
|
</CardTitle>
|
||||||
<Badge variant={batches.length > 0 ? 'default' : 'secondary'}>
|
<Badge variant={batches.length > 0 ? 'default' : 'secondary'}>
|
||||||
{batches.length}
|
{batches.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="text-muted-foreground mb-4 text-sm">
|
||||||
{batches.length > 0
|
{batches.length > 0
|
||||||
? `${batches.length} SEPA-Einzüge mit einem Gesamtvolumen von ${formatCurrency(sepaTotal)}.`
|
? t('payments.batchSummary', {
|
||||||
: 'Keine SEPA-Einzüge vorhanden.'}
|
count: batches.length,
|
||||||
|
total: formatCurrency(sepaTotal),
|
||||||
|
})
|
||||||
|
: t('payments.noBatchesFound')}
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/home/${account}/finance/sepa`}>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Link href={`/home/${account}/finance/sepa`}>
|
||||||
Einzüge anzeigen
|
{t('payments.viewBatches')}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,40 +1,26 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, Download } from 'lucide-react';
|
import { ArrowLeft, Download } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import {
|
||||||
|
BATCH_STATUS_VARIANT,
|
||||||
|
BATCH_STATUS_LABEL_KEYS,
|
||||||
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string; batchId: string }>;
|
params: Promise<{ account: string; batchId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<
|
|
||||||
string,
|
|
||||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
|
||||||
> = {
|
|
||||||
draft: 'secondary',
|
|
||||||
ready: 'default',
|
|
||||||
submitted: 'info',
|
|
||||||
completed: 'outline',
|
|
||||||
failed: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: 'Entwurf',
|
|
||||||
ready: 'Bereit',
|
|
||||||
submitted: 'Eingereicht',
|
|
||||||
completed: 'Abgeschlossen',
|
|
||||||
failed: 'Fehlgeschlagen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ITEM_STATUS_VARIANT: Record<
|
const ITEM_STATUS_VARIANT: Record<
|
||||||
string,
|
string,
|
||||||
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
|
||||||
@@ -44,12 +30,6 @@ const ITEM_STATUS_VARIANT: Record<
|
|||||||
failed: 'destructive',
|
failed: 'destructive',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ITEM_STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: 'Ausstehend',
|
|
||||||
processed: 'Verarbeitet',
|
|
||||||
failed: 'Fehlgeschlagen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: unknown) =>
|
const formatCurrency = (amount: unknown) =>
|
||||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||||
Number(amount),
|
Number(amount),
|
||||||
@@ -57,6 +37,7 @@ const formatCurrency = (amount: unknown) =>
|
|||||||
|
|
||||||
export default async function SepaBatchDetailPage({ params }: PageProps) {
|
export default async function SepaBatchDetailPage({ params }: PageProps) {
|
||||||
const { account, batchId } = await params;
|
const { account, batchId } = await params;
|
||||||
|
const t = await getTranslations('finance');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
@@ -74,12 +55,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
api.getBatchItems(batchId),
|
api.getBatchItems(batchId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!batch) return <div>Einzug nicht gefunden</div>;
|
if (!batch) return <AccountNotFound />;
|
||||||
|
|
||||||
const status = String(batch.status);
|
const status = String(batch.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="SEPA-Einzug Details">
|
<CmsPageShell account={account} title={t('sepa.detailTitle')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div>
|
<div>
|
||||||
@@ -88,7 +69,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Zurück zu SEPA-Lastschriften
|
{t('sepa.backToList')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,27 +77,27 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{String(batch.description ?? 'SEPA-Einzug')}
|
{String(batch.description ?? t('sepa.batchFallbackName'))}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}>
|
<Badge variant={BATCH_STATUS_VARIANT[status] ?? 'secondary'}>
|
||||||
{STATUS_LABEL[status] ?? status}
|
{t(BATCH_STATUS_LABEL_KEYS[status] ?? status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Typ
|
{t('common.type')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{batch.batch_type === 'direct_debit'
|
{batch.batch_type === 'direct_debit'
|
||||||
? 'Lastschrift'
|
? t('sepa.directDebit')
|
||||||
: 'Überweisung'}
|
: t('sepa.creditTransfer')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Betrag
|
{t('common.amount')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{batch.total_amount != null
|
{batch.total_amount != null
|
||||||
@@ -125,23 +106,19 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Anzahl
|
{t('sepa.itemCountLabel')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{String(batch.item_count ?? items.length)}
|
{String(batch.item_count ?? items.length)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-muted-foreground">
|
<dt className="text-muted-foreground text-sm font-medium">
|
||||||
Ausführungsdatum
|
{t('sepa.executionDate')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm font-semibold">
|
<dd className="mt-1 text-sm font-semibold">
|
||||||
{batch.execution_date
|
{formatDate(batch.execution_date)}
|
||||||
? new Date(
|
|
||||||
String(batch.execution_date),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -149,7 +126,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button disabled variant="outline">
|
<Button disabled variant="outline">
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
XML herunterladen
|
{t('sepa.downloadXml')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -158,22 +135,32 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
{/* Items Table */}
|
{/* Items Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Positionen ({items.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('sepa.itemCount')} ({items.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||||
Keine Positionen vorhanden.
|
{t('sepa.noItems')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Name</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">IBAN</th>
|
Name
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
IBAN
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('common.amount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.status')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -182,7 +169,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={String(item.id)}
|
key={String(item.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3 font-medium">
|
<td className="p-3 font-medium">
|
||||||
{String(item.debtor_name ?? '—')}
|
{String(item.debtor_name ?? '—')}
|
||||||
@@ -201,7 +188,11 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
|
|||||||
ITEM_STATUS_VARIANT[itemStatus] ?? 'secondary'
|
ITEM_STATUS_VARIANT[itemStatus] ?? 'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{ITEM_STATUS_LABEL[itemStatus] ?? itemStatus}
|
{t(
|
||||||
|
`sepaItemStatus.${itemStatus}` as Parameters<
|
||||||
|
typeof t
|
||||||
|
>[0],
|
||||||
|
) ?? itemStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { CreateSepaBatchForm } from '@kit/finance/components';
|
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -10,6 +12,7 @@ interface Props {
|
|||||||
|
|
||||||
export default async function NewSepaBatchPage({ params }: Props) {
|
export default async function NewSepaBatchPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const t = await getTranslations('finance');
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
@@ -21,7 +24,11 @@ export default async function NewSepaBatchPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title={t('sepa.newBatch')}
|
||||||
|
description={t('sepa.newBatchDesc')}
|
||||||
|
>
|
||||||
<CreateSepaBatchForm accountId={acct.id} account={account} />
|
<CreateSepaBatchForm accountId={acct.id} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Landmark, Plus } from 'lucide-react';
|
import { Landmark, Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFinanceApi } from '@kit/finance/api';
|
||||||
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { createFinanceApi } from '@kit/finance/api';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
import { EmptyState } from '~/components/empty-state';
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
|
||||||
import {
|
import {
|
||||||
BATCH_STATUS_VARIANT,
|
BATCH_STATUS_VARIANT,
|
||||||
BATCH_STATUS_LABEL,
|
BATCH_STATUS_LABEL_KEYS,
|
||||||
} from '~/lib/status-badges';
|
} from '~/lib/status-badges';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -29,6 +30,7 @@ const formatCurrency = (amount: unknown) =>
|
|||||||
export default async function SepaPage({ params }: PageProps) {
|
export default async function SepaPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('finance');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -39,56 +41,66 @@ export default async function SepaPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createFinanceApi(client);
|
const api = createFinanceApi(client);
|
||||||
const batches = await api.listBatches(acct.id);
|
const batchesResult = await api.listBatches(acct.id);
|
||||||
|
const batches = batchesResult.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="SEPA-Lastschriften">
|
<CmsPageShell account={account} title={t('sepa.title')}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">SEPA-Lastschriften</h1>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Lastschrifteinzüge verwalten
|
Lastschrifteinzüge verwalten
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/home/${account}/finance/sepa/new`}>
|
<Button asChild>
|
||||||
<Button>
|
<Link href={`/home/${account}/finance/sepa/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neuer Einzug
|
{t('nav.newBatch')}
|
||||||
</Button>
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table or Empty State */}
|
{/* Table or Empty State */}
|
||||||
{batches.length === 0 ? (
|
{batches.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Landmark className="h-8 w-8" />}
|
icon={<Landmark className="h-8 w-8" />}
|
||||||
title="Keine SEPA-Einzüge"
|
title={t('sepa.noBatches')}
|
||||||
description="Erstellen Sie Ihren ersten SEPA-Einzug."
|
description={t('sepa.createFirst')}
|
||||||
actionLabel="Neuer Einzug"
|
actionLabel={t('nav.newBatch')}
|
||||||
actionHref={`/home/${account}/finance/sepa/new`}
|
actionHref={`/home/${account}/finance/sepa/new`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Alle Einzüge ({batches.length})</CardTitle>
|
<CardTitle>
|
||||||
|
{t('sepa.title')} ({batches.length})
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="bg-muted/50 border-b">
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-left font-medium">Typ</th>
|
{t('common.status')}
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
Beschreibung
|
|
||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-right font-medium">Betrag</th>
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
<th className="p-3 text-right font-medium">Anzahl</th>
|
{t('common.type')}
|
||||||
<th className="p-3 text-left font-medium">
|
</th>
|
||||||
Ausführungsdatum
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('common.description')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('sepa.totalAmount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-right font-medium">
|
||||||
|
{t('sepa.itemCount')}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="p-3 text-left font-medium">
|
||||||
|
{t('sepa.executionDate')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -96,22 +108,25 @@ export default async function SepaPage({ params }: PageProps) {
|
|||||||
{batches.map((batch: Record<string, unknown>) => (
|
{batches.map((batch: Record<string, unknown>) => (
|
||||||
<tr
|
<tr
|
||||||
key={String(batch.id)}
|
key={String(batch.id)}
|
||||||
className="border-b hover:bg-muted/30"
|
className="hover:bg-muted/30 border-b"
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
|
BATCH_STATUS_VARIANT[String(batch.status)] ??
|
||||||
|
'secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{BATCH_STATUS_LABEL[String(batch.status)] ??
|
{t(
|
||||||
String(batch.status)}
|
BATCH_STATUS_LABEL_KEYS[String(batch.status)] ??
|
||||||
|
String(batch.status),
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{batch.batch_type === 'direct_debit'
|
{batch.batch_type === 'direct_debit'
|
||||||
? 'Lastschrift'
|
? t('sepa.directDebit')
|
||||||
: 'Überweisung'}
|
: t('sepa.creditTransfer')}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -130,11 +145,7 @@ export default async function SepaPage({ params }: PageProps) {
|
|||||||
{String(batch.item_count ?? 0)}
|
{String(batch.item_count ?? 0)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{batch.execution_date
|
{formatDate(batch.execution_date as string | null)}
|
||||||
? new Date(
|
|
||||||
String(batch.execution_date),
|
|
||||||
).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CatchBooksDataTable,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -14,6 +20,7 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -25,7 +32,18 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
const api = createFischereiApi(client);
|
const api = createFischereiApi(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = [
|
||||||
|
{ value: '', label: 'Alle Jahre' },
|
||||||
|
...Array.from({ length: 4 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
const result = await api.listCatchBooks(acct.id, {
|
const result = await api.listCatchBooks(acct.id, {
|
||||||
|
search: search.q as string,
|
||||||
year: search.year ? Number(search.year) : undefined,
|
year: search.year ? Number(search.year) : undefined,
|
||||||
status: search.status as string,
|
status: search.status as string,
|
||||||
page,
|
page,
|
||||||
@@ -33,14 +51,31 @@ export default async function CatchBooksPage({ params, searchParams }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Fangbücher">
|
<CmsPageShell account={account} title={t('pages.catchBooksTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="catch-books" />
|
<FischereiTabNavigation account={account} activeTab="catch-books" />
|
||||||
|
<ListToolbar
|
||||||
|
searchPlaceholder="Mitglied suchen..."
|
||||||
|
filters={[
|
||||||
|
{ param: 'year', label: 'Jahr', options: yearOptions },
|
||||||
|
{
|
||||||
|
param: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'Alle' },
|
||||||
|
{ value: 'open', label: 'Offen' },
|
||||||
|
{ value: 'submitted', label: 'Eingereicht' },
|
||||||
|
{ value: 'checked', label: 'Geprüft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<CatchBooksDataTable
|
<CatchBooksDataTable
|
||||||
data={result.data}
|
data={result.data}
|
||||||
total={result.total}
|
total={result.total}
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
account={account}
|
account={account}
|
||||||
|
accountId={acct.id}
|
||||||
/>
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateCompetitionForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewCompetitionPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createFischereiApi(client);
|
||||||
|
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
|
||||||
|
|
||||||
|
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
|
||||||
|
id: String(w.id),
|
||||||
|
name: String(w.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={t('pages.newCompetitionTitle')}>
|
||||||
|
<FischereiTabNavigation account={account} activeTab="competitions" />
|
||||||
|
<CreateCompetitionForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
waters={waters}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CompetitionsDataTable,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CompetitionsPage({ params, searchParams }: Props) {
|
export default async function CompetitionsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -25,20 +35,37 @@ export default async function CompetitionsPage({ params, searchParams }: Props)
|
|||||||
|
|
||||||
const api = createFischereiApi(client);
|
const api = createFischereiApi(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
const yearParam = search.year ? Number(search.year) : undefined;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = [
|
||||||
|
{ value: '', label: 'Alle Jahre' },
|
||||||
|
...Array.from({ length: 4 }, (_, i) => ({
|
||||||
|
value: String(currentYear - i),
|
||||||
|
label: String(currentYear - i),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
const result = await api.listCompetitions(acct.id, {
|
const result = await api.listCompetitions(acct.id, {
|
||||||
|
year: yearParam,
|
||||||
page,
|
page,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Wettbewerbe">
|
<CmsPageShell account={account} title={t('pages.competitionsTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="competitions" />
|
<FischereiTabNavigation account={account} activeTab="competitions" />
|
||||||
|
<ListToolbar
|
||||||
|
showSearch={false}
|
||||||
|
filters={[{ param: 'year', label: 'Jahr', options: yearOptions }]}
|
||||||
|
/>
|
||||||
<CompetitionsDataTable
|
<CompetitionsDataTable
|
||||||
data={result.data}
|
data={result.data}
|
||||||
total={result.total}
|
total={result.total}
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
account={account}
|
account={account}
|
||||||
|
accountId={acct.id}
|
||||||
/>
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateLeaseForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewLeasePage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createFischereiApi(client);
|
||||||
|
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
|
||||||
|
|
||||||
|
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
|
||||||
|
id: String(w.id),
|
||||||
|
name: String(w.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={t('pages.newLeaseTitle')}>
|
||||||
|
<FischereiTabNavigation account={account} activeTab="leases" />
|
||||||
|
<CreateLeaseForm accountId={acct.id} account={account} waters={waters} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
LeasesDataTable,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { Badge } from '@kit/ui/badge';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -18,6 +24,7 @@ export default async function LeasesPage({ params, searchParams }: Props) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -29,90 +36,66 @@ export default async function LeasesPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
const api = createFischereiApi(client);
|
const api = createFischereiApi(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
const result = await api.listLeases(acct.id, {
|
const waterId = (search.waterId as string) || undefined;
|
||||||
page,
|
const activeParam = search.active as string | undefined;
|
||||||
pageSize: 25,
|
const active =
|
||||||
});
|
activeParam === 'true' ? true : activeParam === 'false' ? false : undefined;
|
||||||
|
|
||||||
|
const [result, watersResult] = await Promise.all([
|
||||||
|
api.listLeases(acct.id, {
|
||||||
|
waterId,
|
||||||
|
active,
|
||||||
|
page,
|
||||||
|
pageSize: 25,
|
||||||
|
}),
|
||||||
|
api.listWaters(acct.id, { pageSize: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const waterOptions = [
|
||||||
|
{ value: '', label: 'Alle Gewässer' },
|
||||||
|
...watersResult.data.map((w) => ({
|
||||||
|
value: String(w.id),
|
||||||
|
label: String(w.name),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Pachten">
|
<CmsPageShell account={account} title={t('pages.leasesTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="leases" />
|
<FischereiTabNavigation account={account} activeTab="leases" />
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Pachten</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Gewässerpachtverträge verwalten
|
Gewässerpachtverträge verwalten
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" data-test="leases-new-btn" asChild>
|
||||||
|
<Link href={`/home/${account}/fischerei/leases/new`}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Neue Pacht
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<ListToolbar
|
||||||
<CardHeader>
|
showSearch={false}
|
||||||
<CardTitle>Pachten ({result.total})</CardTitle>
|
filters={[
|
||||||
</CardHeader>
|
{ param: 'waterId', label: 'Gewässer', options: waterOptions },
|
||||||
<CardContent>
|
{
|
||||||
{result.data.length === 0 ? (
|
param: 'active',
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
label: 'Status',
|
||||||
<h3 className="text-lg font-semibold">
|
options: [
|
||||||
Keine Pachten vorhanden
|
{ value: '', label: 'Alle' },
|
||||||
</h3>
|
{ value: 'true', label: 'Aktiv' },
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
{ value: 'false', label: 'Archiviert' },
|
||||||
Erstellen Sie Ihren ersten Pachtvertrag.
|
],
|
||||||
</p>
|
},
|
||||||
</div>
|
]}
|
||||||
) : (
|
/>
|
||||||
<div className="rounded-md border">
|
<LeasesDataTable
|
||||||
<table className="w-full text-sm">
|
data={result.data}
|
||||||
<thead>
|
total={result.total}
|
||||||
<tr className="border-b bg-muted/50">
|
accountId={acct.id}
|
||||||
<th className="p-3 text-left font-medium">Verpächter</th>
|
/>
|
||||||
<th className="p-3 text-left font-medium">Gewässer</th>
|
|
||||||
<th className="p-3 text-left font-medium">Beginn</th>
|
|
||||||
<th className="p-3 text-left font-medium">Ende</th>
|
|
||||||
<th className="p-3 text-right font-medium">Jahresbetrag (€)</th>
|
|
||||||
<th className="p-3 text-left font-medium">Zahlungsart</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.data.map((lease: Record<string, unknown>) => {
|
|
||||||
const waters = lease.waters as Record<string, unknown> | null;
|
|
||||||
const paymentMethod = String(lease.payment_method ?? 'ueberweisung');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={String(lease.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">
|
|
||||||
{String(lease.lessor_name)}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{waters ? String(waters.name) : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{lease.start_date
|
|
||||||
? new Date(String(lease.start_date)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{lease.end_date
|
|
||||||
? new Date(String(lease.end_date)).toLocaleDateString('de-DE')
|
|
||||||
: 'unbefristet'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
{lease.initial_amount != null
|
|
||||||
? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
FischereiDashboard,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -12,6 +17,7 @@ interface PageProps {
|
|||||||
export default async function FischereiPage({ params }: PageProps) {
|
export default async function FischereiPage({ params }: PageProps) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -25,7 +31,7 @@ export default async function FischereiPage({ params }: PageProps) {
|
|||||||
const stats = await api.getDashboardStats(acct.id);
|
const stats = await api.getDashboardStats(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei">
|
<CmsPageShell account={account} title={t('pages.overviewTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="overview" />
|
<FischereiTabNavigation account={account} activeTab="overview" />
|
||||||
<FischereiDashboard stats={stats} account={account} />
|
<FischereiDashboard stats={stats} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreatePermitForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewPermitPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createFischereiApi(client);
|
||||||
|
const watersResult = await api.listWaters(acct.id, { pageSize: 200 });
|
||||||
|
|
||||||
|
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
|
||||||
|
id: String(w.id),
|
||||||
|
name: String(w.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={t('pages.newPermitTitle')}>
|
||||||
|
<FischereiTabNavigation account={account} activeTab="permits" />
|
||||||
|
<CreatePermitForm accountId={acct.id} account={account} waters={waters} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import Link from 'next/link';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
PermitsDataTable,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -13,6 +21,7 @@ interface Props {
|
|||||||
export default async function PermitsPage({ params }: Props) {
|
export default async function PermitsPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -26,71 +35,26 @@ export default async function PermitsPage({ params }: Props) {
|
|||||||
const permits = await api.listPermits(acct.id);
|
const permits = await api.listPermits(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Erlaubnisscheine">
|
<CmsPageShell account={account} title={t('pages.permitsTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="permits" />
|
<FischereiTabNavigation account={account} activeTab="permits" />
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Erlaubnisscheine und Gewässerkarten verwalten
|
Erlaubnisscheine und Gewässerkarten verwalten
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" data-test="permits-new-btn" asChild>
|
||||||
|
<Link href={`/home/${account}/fischerei/permits/new`}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Neuer Erlaubnisschein
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<PermitsDataTable
|
||||||
<CardHeader>
|
data={permits as Array<Record<string, unknown>>}
|
||||||
<CardTitle>Erlaubnisscheine ({permits.length})</CardTitle>
|
accountId={acct.id}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
|
||||||
{permits.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Keine Erlaubnisscheine vorhanden
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
|
||||||
Erstellen Sie Ihren ersten Erlaubnisschein.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/50">
|
|
||||||
<th className="p-3 text-left font-medium">Bezeichnung</th>
|
|
||||||
<th className="p-3 text-left font-medium">Kurzcode</th>
|
|
||||||
<th className="p-3 text-left font-medium">Hauptgewässer</th>
|
|
||||||
<th className="p-3 text-right font-medium">Gesamtmenge</th>
|
|
||||||
<th className="p-3 text-center font-medium">Zum Verkauf</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{permits.map((permit: Record<string, unknown>) => {
|
|
||||||
const waters = permit.waters as Record<string, unknown> | null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={String(permit.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">{String(permit.name)}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">
|
|
||||||
{String(permit.short_code ?? '—')}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{waters ? String(waters.name) : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
{permit.total_quantity != null
|
|
||||||
? String(permit.total_quantity)
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-center">
|
|
||||||
{permit.is_for_sale ? '✓' : '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateSpeciesForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string; speciesId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditSpeciesPage({ params }: Props) {
|
||||||
|
const { account, speciesId } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createFischereiApi(client);
|
||||||
|
|
||||||
|
let species;
|
||||||
|
|
||||||
|
try {
|
||||||
|
species = await api.getSpecies(speciesId);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={t('pages.editSpeciesTitle')}>
|
||||||
|
<FischereiTabNavigation account={account} activeTab="species" />
|
||||||
|
<CreateSpeciesForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
species={species}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { FischereiTabNavigation, CreateSpeciesForm } from '@kit/fischerei/components';
|
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateSpeciesForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -11,6 +16,7 @@ interface Props {
|
|||||||
export default async function NewSpeciesPage({ params }: Props) {
|
export default async function NewSpeciesPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -21,7 +27,7 @@ export default async function NewSpeciesPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Fischart">
|
<CmsPageShell account={account} title={t('pages.newSpeciesTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="species" />
|
<FischereiTabNavigation account={account} activeTab="species" />
|
||||||
<CreateSpeciesForm accountId={acct.id} account={account} />
|
<CreateSpeciesForm accountId={acct.id} account={account} />
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation, SpeciesDataTable } from '@kit/fischerei/components';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
SpeciesDataTable,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { ListToolbar } from '@kit/ui/list-toolbar';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -14,6 +20,7 @@ export default async function SpeciesPage({ params, searchParams }: Props) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const search = await searchParams;
|
const search = await searchParams;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -25,21 +32,41 @@ export default async function SpeciesPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
const api = createFischereiApi(client);
|
const api = createFischereiApi(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
const activeParam = search.active as string | undefined;
|
||||||
|
const active =
|
||||||
|
activeParam === 'true' ? true : activeParam === 'false' ? false : undefined;
|
||||||
|
|
||||||
const result = await api.listSpecies(acct.id, {
|
const result = await api.listSpecies(acct.id, {
|
||||||
search: search.q as string,
|
search: search.q as string,
|
||||||
|
active,
|
||||||
page,
|
page,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Fischarten">
|
<CmsPageShell account={account} title={t('pages.speciesTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="species" />
|
<FischereiTabNavigation account={account} activeTab="species" />
|
||||||
|
<ListToolbar
|
||||||
|
searchPlaceholder="Fischart suchen..."
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
param: 'active',
|
||||||
|
label: 'Status',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'Alle' },
|
||||||
|
{ value: 'true', label: 'Aktiv' },
|
||||||
|
{ value: 'false', label: 'Inaktiv' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<SpeciesDataTable
|
<SpeciesDataTable
|
||||||
data={result.data}
|
data={result.data}
|
||||||
total={result.total}
|
total={result.total}
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
account={account}
|
account={account}
|
||||||
|
accountId={acct.id}
|
||||||
/>
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { FischereiTabNavigation } from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -12,6 +14,7 @@ interface Props {
|
|||||||
export default async function StatisticsPage({ params }: Props) {
|
export default async function StatisticsPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -22,11 +25,10 @@ export default async function StatisticsPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Fischerei - Statistiken">
|
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="statistics" />
|
<FischereiTabNavigation account={account} activeTab="statistics" />
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Statistiken</h1>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Fangstatistiken und Auswertungen
|
Fangstatistiken und Auswertungen
|
||||||
</p>
|
</p>
|
||||||
@@ -37,9 +39,12 @@ export default async function StatisticsPage({ params }: Props) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||||
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
|
<h3 className="text-lg font-semibold">
|
||||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
Noch keine Daten vorhanden
|
||||||
Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen.
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
|
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
|
||||||
|
hier Statistiken und Auswertungen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateStockingForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string; stockingId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditStockingPage({ params }: Props) {
|
||||||
|
const { account, stockingId } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createFischereiApi(client);
|
||||||
|
|
||||||
|
let stocking;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stocking = await api.getStocking(stockingId);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load waters and species lists for form dropdowns
|
||||||
|
const [watersResult, speciesResult] = await Promise.all([
|
||||||
|
api.listWaters(acct.id, { pageSize: 200 }),
|
||||||
|
api.listSpecies(acct.id, { pageSize: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
|
||||||
|
id: String(w.id),
|
||||||
|
name: String(w.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const speciesList = speciesResult.data.map((s: Record<string, unknown>) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: String(s.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={t('pages.editStockingTitle')}>
|
||||||
|
<FischereiTabNavigation account={account} activeTab="stocking" />
|
||||||
|
<CreateStockingForm
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
waters={waters}
|
||||||
|
species={speciesList}
|
||||||
|
stocking={stocking}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { createFischereiApi } from '@kit/fischerei/api';
|
|
||||||
import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components';
|
import { createFischereiApi } from '@kit/fischerei/api';
|
||||||
|
import {
|
||||||
|
FischereiTabNavigation,
|
||||||
|
CreateStockingForm,
|
||||||
|
} from '@kit/fischerei/components';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -12,6 +17,7 @@ interface Props {
|
|||||||
export default async function NewStockingPage({ params }: Props) {
|
export default async function NewStockingPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('fischerei');
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -40,7 +46,7 @@ export default async function NewStockingPage({ params }: Props) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Besatz eintragen">
|
<CmsPageShell account={account} title={t('pages.newStockingTitle')}>
|
||||||
<FischereiTabNavigation account={account} activeTab="stocking" />
|
<FischereiTabNavigation account={account} activeTab="stocking" />
|
||||||
<CreateStockingForm
|
<CreateStockingForm
|
||||||
accountId={acct.id}
|
accountId={acct.id}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user