feat: MyEasyCMS v2 — Full SaaS rebuild
Complete rebuild of 22-year-old PHP CMS as modern SaaS: Database (15 migrations, 42+ tables): - Foundation: account_settings, audit_log, GDPR register, cms_files - Module Engine: modules, fields, records, permissions, relations + RPC - Members: 45+ field member profiles, departments, roles, honors, SEPA mandates - Courses: courses, sessions, categories, instructors, locations, attendance - Bookings: rooms, guests, bookings with availability - Events: events, registrations, holiday passes - Finance: SEPA batches/items (pain.008/001 XML), invoices - Newsletter: campaigns, templates, recipients, subscriptions - Site Builder: site_pages (Puck JSON), site_settings, cms_posts - Portal Auth: member_portal_invitations, user linking Feature Packages (9): - @kit/module-builder — dynamic low-code CRUD engine - @kit/member-management — 31 API methods, 21 actions, 8 components - @kit/course-management, @kit/booking-management, @kit/event-management - @kit/finance — SEPA XML generator + IBAN validator - @kit/newsletter — campaigns + dispatch - @kit/document-generator — PDF/Excel/Word - @kit/site-builder — Puck visual editor, 15 blocks, public rendering Pages (60+): - Dashboard with real stats from all APIs - Full CRUD for all 8 domains with react-hook-form + Zod - Recharts statistics - German i18n throughout - Member portal with auth + invitation system - Public club websites via Puck at /club/[slug] Infrastructure: - Dockerfile (multi-stage, standalone output) - docker-compose.yml (Supabase self-hosted + Next.js) - Kong API gateway config - .env.production.example
This commit is contained in:
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-cli
|
||||||
|
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitNexus CLI Commands
|
||||||
|
|
||||||
|
All commands work via `npx` — no global install required.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### analyze — Build or refresh the index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| -------------- | ---------------------------------------------------------------- |
|
||||||
|
| `--force` | Force full re-index even if up to date |
|
||||||
|
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||||
|
|
||||||
|
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||||
|
|
||||||
|
### status — Check index freshness
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus status
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||||
|
|
||||||
|
### clean — Delete the index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| --------- | ------------------------------------------------- |
|
||||||
|
| `--force` | Skip confirmation prompt |
|
||||||
|
| `--all` | Clean all indexed repos, not just the current one |
|
||||||
|
|
||||||
|
### wiki — Generate documentation from the graph
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus wiki
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| ------------------- | ----------------------------------------- |
|
||||||
|
| `--force` | Force full regeneration |
|
||||||
|
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||||
|
| `--base-url <url>` | LLM API base URL |
|
||||||
|
| `--api-key <key>` | LLM API key |
|
||||||
|
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||||
|
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||||
|
|
||||||
|
### list — Show all indexed repos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus list
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||||
|
|
||||||
|
## After Indexing
|
||||||
|
|
||||||
|
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||||
|
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||||
|
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||||
|
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-debugging
|
||||||
|
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debugging with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Why is this function failing?"
|
||||||
|
- "Trace where this error comes from"
|
||||||
|
- "Who calls this method?"
|
||||||
|
- "This endpoint returns 500"
|
||||||
|
- Investigating bugs, errors, or unexpected behavior
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||||
|
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||||
|
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||||
|
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||||
|
- [ ] gitnexus_query for error text or related code
|
||||||
|
- [ ] Identify the suspect function from returned processes
|
||||||
|
- [ ] gitnexus_context to see callers and callees
|
||||||
|
- [ ] Trace execution flow via process resource if applicable
|
||||||
|
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||||
|
- [ ] Read source files to confirm root cause
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Patterns
|
||||||
|
|
||||||
|
| Symptom | GitNexus Approach |
|
||||||
|
| -------------------- | ---------------------------------------------------------- |
|
||||||
|
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||||
|
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||||
|
| Intermittent failure | `context` → look for external calls, async deps |
|
||||||
|
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||||
|
| Recent regression | `detect_changes` to see what your changes affect |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_query** — find code related to error:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_query({query: "payment validation error"})
|
||||||
|
→ Processes: CheckoutFlow, ErrorHandling
|
||||||
|
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_context** — full context for a suspect:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_context({name: "validatePayment"})
|
||||||
|
→ Incoming calls: processCheckout, webhookHandler
|
||||||
|
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||||
|
→ Processes: CheckoutFlow (step 3/7)
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_cypher** — custom call chain traces:
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||||
|
RETURN [n IN nodes(path) | n.name] AS chain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "Payment endpoint returns 500 intermittently"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_query({query: "payment error handling"})
|
||||||
|
→ Processes: CheckoutFlow, ErrorHandling
|
||||||
|
→ Symbols: validatePayment, handlePaymentError
|
||||||
|
|
||||||
|
2. gitnexus_context({name: "validatePayment"})
|
||||||
|
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||||
|
|
||||||
|
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||||
|
→ Step 3: validatePayment → calls fetchRates (external)
|
||||||
|
|
||||||
|
4. Root cause: fetchRates calls external API without proper timeout
|
||||||
|
```
|
||||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-exploring
|
||||||
|
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Exploring Codebases with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "How does authentication work?"
|
||||||
|
- "What's the project structure?"
|
||||||
|
- "Show me the main components"
|
||||||
|
- "Where is the database logic?"
|
||||||
|
- Understanding code you haven't seen before
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. READ gitnexus://repos → Discover indexed repos
|
||||||
|
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||||
|
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||||
|
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||||
|
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||||
|
```
|
||||||
|
|
||||||
|
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] READ gitnexus://repo/{name}/context
|
||||||
|
- [ ] gitnexus_query for the concept you want to understand
|
||||||
|
- [ ] Review returned processes (execution flows)
|
||||||
|
- [ ] gitnexus_context on key symbols for callers/callees
|
||||||
|
- [ ] READ process resource for full execution traces
|
||||||
|
- [ ] Read source files for implementation details
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | What you get |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||||
|
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_query** — find execution flows related to a concept:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_query({query: "payment processing"})
|
||||||
|
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||||
|
→ Symbols grouped by flow with file locations
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_context** — 360-degree view of a symbol:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_context({name: "validateUser"})
|
||||||
|
→ Incoming calls: loginHandler, apiMiddleware
|
||||||
|
→ Outgoing calls: checkToken, getUserById
|
||||||
|
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "How does payment processing work?"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||||
|
2. gitnexus_query({query: "payment processing"})
|
||||||
|
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||||
|
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||||
|
3. gitnexus_context({name: "processPayment"})
|
||||||
|
→ Incoming: checkoutHandler, webhookHandler
|
||||||
|
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||||
|
4. Read src/payments/processor.ts for implementation details
|
||||||
|
```
|
||||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-guide
|
||||||
|
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitNexus Guide
|
||||||
|
|
||||||
|
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||||
|
|
||||||
|
## Always Start Here
|
||||||
|
|
||||||
|
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||||
|
|
||||||
|
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||||
|
2. **Match your task to a skill below** and **read that skill file**
|
||||||
|
3. **Follow the skill's workflow and checklist**
|
||||||
|
|
||||||
|
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
| Task | Skill to read |
|
||||||
|
| -------------------------------------------- | ------------------- |
|
||||||
|
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||||
|
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||||
|
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||||
|
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||||
|
|
||||||
|
## Tools Reference
|
||||||
|
|
||||||
|
| Tool | What it gives you |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||||
|
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||||
|
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||||
|
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||||
|
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||||
|
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||||
|
| `list_repos` | Discover indexed repos |
|
||||||
|
|
||||||
|
## Resources Reference
|
||||||
|
|
||||||
|
Lightweight reads (~100-500 tokens) for navigation:
|
||||||
|
|
||||||
|
| Resource | Content |
|
||||||
|
| ---------------------------------------------- | ----------------------------------------- |
|
||||||
|
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||||
|
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||||
|
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||||
|
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||||
|
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||||
|
|
||||||
|
## Graph Schema
|
||||||
|
|
||||||
|
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||||
|
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||||
|
RETURN caller.name, caller.filePath
|
||||||
|
```
|
||||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-impact-analysis
|
||||||
|
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Impact Analysis with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Is it safe to change this function?"
|
||||||
|
- "What will break if I modify X?"
|
||||||
|
- "Show me the blast radius"
|
||||||
|
- "Who uses this code?"
|
||||||
|
- Before making non-trivial code changes
|
||||||
|
- Before committing — to understand what your changes affect
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||||
|
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||||
|
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||||
|
4. Assess risk and report to user
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||||
|
- [ ] Review d=1 items first (these WILL BREAK)
|
||||||
|
- [ ] Check high-confidence (>0.8) dependencies
|
||||||
|
- [ ] READ processes to check affected execution flows
|
||||||
|
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||||
|
- [ ] Assess risk level and report to user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding Output
|
||||||
|
|
||||||
|
| Depth | Risk Level | Meaning |
|
||||||
|
| ----- | ---------------- | ------------------------ |
|
||||||
|
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||||
|
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||||
|
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Affected | Risk |
|
||||||
|
| ------------------------------ | -------- |
|
||||||
|
| <5 symbols, few processes | LOW |
|
||||||
|
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||||
|
| >15 symbols or many processes | HIGH |
|
||||||
|
| Critical path (auth, payments) | CRITICAL |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_impact({
|
||||||
|
target: "validateUser",
|
||||||
|
direction: "upstream",
|
||||||
|
minConfidence: 0.8,
|
||||||
|
maxDepth: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
→ d=1 (WILL BREAK):
|
||||||
|
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||||
|
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||||
|
|
||||||
|
→ d=2 (LIKELY AFFECTED):
|
||||||
|
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_detect_changes({scope: "staged"})
|
||||||
|
|
||||||
|
→ Changed: 5 symbols in 3 files
|
||||||
|
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||||
|
→ Risk: MEDIUM
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: "What breaks if I change validateUser?"
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||||
|
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||||
|
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||||
|
|
||||||
|
2. READ gitnexus://repo/my-app/processes
|
||||||
|
→ LoginFlow and TokenRefresh touch validateUser
|
||||||
|
|
||||||
|
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||||
|
```
|
||||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
name: gitnexus-refactoring
|
||||||
|
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactoring with GitNexus
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- "Rename this function safely"
|
||||||
|
- "Extract this into a module"
|
||||||
|
- "Split this service"
|
||||||
|
- "Move this to a new file"
|
||||||
|
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||||
|
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||||
|
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||||
|
4. Plan update order: interfaces → implementations → callers → tests
|
||||||
|
```
|
||||||
|
|
||||||
|
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||||
|
|
||||||
|
## Checklists
|
||||||
|
|
||||||
|
### Rename Symbol
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||||
|
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||||
|
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||||
|
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extract Module
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||||
|
- [ ] Define new module interface
|
||||||
|
- [ ] Extract code, update imports
|
||||||
|
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Split Function/Service
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||||
|
- [ ] Group callees by responsibility
|
||||||
|
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||||
|
- [ ] Create new functions/services
|
||||||
|
- [ ] Update callers
|
||||||
|
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||||
|
- [ ] Run tests for affected processes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
**gitnexus_rename** — automated multi-file rename:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||||
|
→ 12 edits across 8 files
|
||||||
|
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||||
|
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_impact** — map all dependents first:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||||
|
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||||
|
→ Affected Processes: LoginFlow, TokenRefresh
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitnexus_detect_changes({scope: "all"})
|
||||||
|
→ Changed: 8 files, 12 symbols
|
||||||
|
→ Affected processes: LoginFlow, TokenRefresh
|
||||||
|
→ Risk: MEDIUM
|
||||||
|
```
|
||||||
|
|
||||||
|
**gitnexus_cypher** — custom reference queries:
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||||
|
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risk Rules
|
||||||
|
|
||||||
|
| Risk Factor | Mitigation |
|
||||||
|
| ------------------- | ----------------------------------------- |
|
||||||
|
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||||
|
| Cross-area refs | Use detect_changes after to verify scope |
|
||||||
|
| String/dynamic refs | gitnexus_query to find them |
|
||||||
|
| External/public API | Version and deprecate properly |
|
||||||
|
|
||||||
|
## Example: Rename `validateUser` to `authenticateUser`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||||
|
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||||
|
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||||
|
|
||||||
|
2. Review ast_search edits (config.json: dynamic reference!)
|
||||||
|
|
||||||
|
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||||
|
→ Applied 12 edits across 8 files
|
||||||
|
|
||||||
|
4. gitnexus_detect_changes({scope: "all"})
|
||||||
|
→ Affected: LoginFlow, TokenRefresh
|
||||||
|
→ Risk: MEDIUM — run tests for these flows
|
||||||
|
```
|
||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.turbo
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
|
apps/e2e
|
||||||
|
apps/dev-tool
|
||||||
|
.gitnexus
|
||||||
|
.gsd
|
||||||
|
.claude
|
||||||
48
.env.production.example
Normal file
48
.env.production.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# =====================================================
|
||||||
|
# MyEasyCMS v2 — Environment Variables
|
||||||
|
# Copy to .env and fill in your values
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
# --- Supabase ---
|
||||||
|
POSTGRES_PASSWORD=change-me-to-a-strong-password
|
||||||
|
JWT_SECRET=change-me-to-at-least-32-characters-long-secret
|
||||||
|
|
||||||
|
# Generate these with: npx supabase gen keys
|
||||||
|
SUPABASE_ANON_KEY=your-anon-key-here
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
SITE_URL=https://myeasycms.de
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# --- Kong ---
|
||||||
|
KONG_HTTP_PORT=8000
|
||||||
|
KONG_HTTPS_PORT=8443
|
||||||
|
API_EXTERNAL_URL=https://api.myeasycms.de
|
||||||
|
|
||||||
|
# --- Email (SMTP) ---
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@myeasycms.de
|
||||||
|
SMTP_PASS=your-smtp-password
|
||||||
|
SMTP_ADMIN_EMAIL=admin@myeasycms.de
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||||
|
DISABLE_SIGNUP=false
|
||||||
|
JWT_EXPIRY=3600
|
||||||
|
ADDITIONAL_REDIRECT_URLS=
|
||||||
|
|
||||||
|
# --- Webhooks ---
|
||||||
|
DB_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
|
# --- Feature Flags ---
|
||||||
|
# All default to true, set to false to disable
|
||||||
|
# NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_MEMBER_MANAGEMENT=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_COURSE_MANAGEMENT=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_BOOKING_MANAGEMENT=false
|
||||||
|
# NEXT_PUBLIC_ENABLE_SEPA_PAYMENTS=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_DOCUMENT_GENERATION=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_NEWSLETTER=true
|
||||||
|
# NEXT_PUBLIC_ENABLE_SITE_BUILDER=true
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ node-compile-cache/
|
|||||||
|
|
||||||
# prds
|
# prds
|
||||||
.prds/
|
.prds/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
102
AGENTS.md
102
AGENTS.md
@@ -68,3 +68,105 @@ After implementation, always run:
|
|||||||
2. `pnpm lint:fix`
|
2. `pnpm lint:fix`
|
||||||
3. `pnpm format:fix`
|
3. `pnpm format:fix`
|
||||||
4. Run code quality reviewer agent
|
4. Run code quality reviewer agent
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/myeasycms-v2/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/myeasycms-v2/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
102
CLAUDE.md
102
CLAUDE.md
@@ -1 +1,103 @@
|
|||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/myeasycms-v2/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/myeasycms-v2/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/myeasycms-v2/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# =====================================================
|
||||||
|
# MyEasyCMS v2 — Production Dockerfile
|
||||||
|
# Multi-stage build for Next.js with pnpm
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- Dependencies stage ---
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
|
COPY packages/ ./packages/
|
||||||
|
COPY tooling/ ./tooling/
|
||||||
|
RUN pnpm install --frozen-lockfile --prod=false
|
||||||
|
|
||||||
|
# --- Build stage ---
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/packages/ ./packages/
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set build-time env vars
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=https://myeasycms.de
|
||||||
|
|
||||||
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
|
# --- Production stage ---
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy built app
|
||||||
|
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||||
|
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
BookOpenIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
GraduationCapIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
MailIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
UsersIcon,
|
||||||
|
WalletIcon,
|
||||||
|
BedDoubleIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
ZapIcon,
|
||||||
|
HeadsetIcon,
|
||||||
|
LockIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
CheckIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PricingTable } from '@kit/billing-gateway/marketing';
|
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||||
import {
|
import {
|
||||||
CtaButton,
|
CtaButton,
|
||||||
EcosystemShowcase,
|
EcosystemShowcase,
|
||||||
FeatureCard,
|
|
||||||
FeatureGrid,
|
|
||||||
FeatureShowcase,
|
FeatureShowcase,
|
||||||
FeatureShowcaseIconContainer,
|
FeatureShowcaseIconContainer,
|
||||||
Hero,
|
Hero,
|
||||||
Pill,
|
Pill,
|
||||||
PillActionButton,
|
|
||||||
SecondaryHero,
|
SecondaryHero,
|
||||||
} from '@kit/ui/marketing';
|
} from '@kit/ui/marketing';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
@@ -23,31 +38,25 @@ import pathsConfig from '~/config/paths.config';
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={'mt-4 flex flex-col space-y-24 py-14'}>
|
<div className={'mt-4 flex flex-col space-y-24 py-14 lg:space-y-36'}>
|
||||||
|
{/* Hero Section */}
|
||||||
<div className={'mx-auto'}>
|
<div className={'mx-auto'}>
|
||||||
<Hero
|
<Hero
|
||||||
pill={
|
pill={
|
||||||
<Pill label={'New'}>
|
<Pill label={'Neu'}>
|
||||||
<span>The SaaS Starter Kit for ambitious developers</span>
|
<span>
|
||||||
<PillActionButton
|
<Trans i18nKey={'marketing.heroPill'} />
|
||||||
render={
|
</span>
|
||||||
<Link href={'/auth/sign-up'}>
|
|
||||||
<ArrowRightIcon className={'h-4 w-4'} />
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Pill>
|
</Pill>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<span className="text-secondary-foreground">
|
<span className="text-secondary-foreground">
|
||||||
<span>Ship a SaaS faster than ever.</span>
|
<Trans i18nKey={'marketing.heroTitle'} />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
<span>
|
<span>
|
||||||
Makerkit gives you a production-ready boilerplate to build your
|
<Trans i18nKey={'marketing.heroSubtitle'} />
|
||||||
SaaS faster than ever before with the next-gen SaaS Starter Kit.
|
|
||||||
Get started in minutes.
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
cta={<MainCallToActionButton />}
|
cta={<MainCallToActionButton />}
|
||||||
@@ -55,95 +64,227 @@ function Home() {
|
|||||||
<Image
|
<Image
|
||||||
priority
|
priority
|
||||||
className={
|
className={
|
||||||
'dark:border-primary/10 w-full rounded-lg border border-gray-200'
|
'dark:border-primary/10 w-full rounded-2xl border border-gray-200 shadow-2xl'
|
||||||
}
|
}
|
||||||
width={3558}
|
width={3558}
|
||||||
height={2222}
|
height={2222}
|
||||||
src={`/images/dashboard.webp`}
|
src={`/images/dashboard.webp`}
|
||||||
alt={`App Image`}
|
alt={`MyEasyCMS Dashboard`}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium uppercase tracking-widest">
|
||||||
|
<Trans i18nKey={'marketing.trustedBy'} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
||||||
|
<TrustItem icon={UsersIcon} label="marketing.trustAssociations" />
|
||||||
|
<TrustItem
|
||||||
|
icon={GraduationCapIcon}
|
||||||
|
label="marketing.trustSchools"
|
||||||
|
/>
|
||||||
|
<TrustItem icon={BookOpenIcon} label="marketing.trustClubs" />
|
||||||
|
<TrustItem
|
||||||
|
icon={GlobeIcon}
|
||||||
|
label="marketing.trustOrganizations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Modules Feature Grid */}
|
||||||
<div className={'container mx-auto'}>
|
<div className={'container mx-auto'}>
|
||||||
<div className={'py-4 xl:py-8'}>
|
<div className={'py-4 xl:py-8'}>
|
||||||
<FeatureShowcase
|
<FeatureShowcase
|
||||||
heading={
|
heading={
|
||||||
<>
|
<>
|
||||||
<b className="font-medium tracking-tight dark:text-white">
|
<b className="font-medium tracking-tight dark:text-white">
|
||||||
The ultimate SaaS Starter Kit
|
<Trans i18nKey={'marketing.featuresHeading'} />
|
||||||
</b>
|
</b>
|
||||||
.{' '}
|
.{' '}
|
||||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||||
Unleash your creativity and build your SaaS faster than ever
|
<Trans i18nKey={'marketing.featuresSubheading'} />
|
||||||
with Makerkit.
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
<FeatureShowcaseIconContainer>
|
<FeatureShowcaseIconContainer>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboardIcon className="h-4 w-4" />
|
||||||
<span>All-in-one solution</span>
|
<span>
|
||||||
|
<Trans i18nKey={'marketing.featuresLabel'} />
|
||||||
|
</span>
|
||||||
</FeatureShowcaseIconContainer>
|
</FeatureShowcaseIconContainer>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FeatureGrid>
|
<div className="mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<FeatureCard
|
<IconFeatureCard
|
||||||
className={'relative col-span-1 overflow-hidden'}
|
icon={UsersIcon}
|
||||||
label={'Beautiful Dashboard'}
|
titleKey="marketing.featureMembersTitle"
|
||||||
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
|
descKey="marketing.featureMembersDesc"
|
||||||
></FeatureCard>
|
|
||||||
|
|
||||||
<FeatureCard
|
|
||||||
className={'relative col-span-1 w-full overflow-hidden'}
|
|
||||||
label={'Authentication'}
|
|
||||||
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
|
|
||||||
></FeatureCard>
|
|
||||||
|
|
||||||
<FeatureCard
|
|
||||||
className={'relative col-span-1 overflow-hidden'}
|
|
||||||
label={'Multi Tenancy'}
|
|
||||||
description={`Multi tenant memberships for your SaaS business.`}
|
|
||||||
/>
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
<FeatureCard
|
icon={GraduationCapIcon}
|
||||||
className={'relative col-span-1 overflow-hidden'}
|
titleKey="marketing.featureCoursesTitle"
|
||||||
label={'Billing'}
|
descKey="marketing.featureCoursesDesc"
|
||||||
description={`Makerkit supports multiple payment gateways to charge your customers.`}
|
|
||||||
/>
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
<FeatureCard
|
icon={BedDoubleIcon}
|
||||||
className={'relative col-span-1 overflow-hidden'}
|
titleKey="marketing.featureBookingsTitle"
|
||||||
label={'Plugins'}
|
descKey="marketing.featureBookingsDesc"
|
||||||
description={`Extend your SaaS with plugins that you can install using the CLI.`}
|
|
||||||
/>
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
<FeatureCard
|
icon={CalendarIcon}
|
||||||
className={'relative col-span-1 overflow-hidden'}
|
titleKey="marketing.featureEventsTitle"
|
||||||
label={'Documentation'}
|
descKey="marketing.featureEventsDesc"
|
||||||
description={`Makerkit provides a comprehensive documentation to help you get started.`}
|
|
||||||
/>
|
/>
|
||||||
</FeatureGrid>
|
<IconFeatureCard
|
||||||
|
icon={WalletIcon}
|
||||||
|
titleKey="marketing.featureFinanceTitle"
|
||||||
|
descKey="marketing.featureFinanceDesc"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={MailIcon}
|
||||||
|
titleKey="marketing.featureNewsletterTitle"
|
||||||
|
descKey="marketing.featureNewsletterDesc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FeatureShowcase>
|
</FeatureShowcase>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard Showcase */}
|
||||||
<div className={'container mx-auto'}>
|
<div className={'container mx-auto'}>
|
||||||
<EcosystemShowcase
|
<EcosystemShowcase
|
||||||
heading="The ultimate SaaS Starter Kit for founders."
|
heading={<Trans i18nKey={'marketing.showcaseHeading'} />}
|
||||||
description="Unleash your creativity and build your SaaS faster than ever with Makerkit. Get started in minutes and ship your SaaS in no time."
|
description={<Trans i18nKey={'marketing.showcaseDescription'} />}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
className="rounded-md"
|
className="rounded-lg shadow-lg"
|
||||||
src={'/images/sign-in.webp'}
|
src={'/images/dashboard.webp'}
|
||||||
alt="Sign in"
|
alt="MyEasyCMS Dashboard"
|
||||||
width={1000}
|
width={1200}
|
||||||
height={1000}
|
height={800}
|
||||||
/>
|
/>
|
||||||
</EcosystemShowcase>
|
</EcosystemShowcase>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Features Row */}
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<div className={'py-4 xl:py-8'}>
|
||||||
|
<FeatureShowcase
|
||||||
|
heading={
|
||||||
|
<>
|
||||||
|
<b className="font-medium tracking-tight dark:text-white">
|
||||||
|
<Trans i18nKey={'marketing.additionalFeaturesHeading'} />
|
||||||
|
</b>
|
||||||
|
.{' '}
|
||||||
|
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||||
|
<Trans
|
||||||
|
i18nKey={'marketing.additionalFeaturesSubheading'}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<FeatureShowcaseIconContainer>
|
||||||
|
<ZapIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey={'marketing.additionalFeaturesLabel'} />
|
||||||
|
</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
|
||||||
|
icon={FileTextIcon}
|
||||||
|
titleKey="marketing.featureDocumentsTitle"
|
||||||
|
descKey="marketing.featureDocumentsDesc"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={GlobeIcon}
|
||||||
|
titleKey="marketing.featureSiteBuilderTitle"
|
||||||
|
descKey="marketing.featureSiteBuilderDesc"
|
||||||
|
/>
|
||||||
|
<IconFeatureCard
|
||||||
|
icon={LayoutDashboardIcon}
|
||||||
|
titleKey="marketing.featureModulesTitle"
|
||||||
|
descKey="marketing.featureModulesDesc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FeatureShowcase>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Why Choose Us Section */}
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
|
<EcosystemShowcase
|
||||||
|
heading={<Trans i18nKey={'marketing.whyChooseHeading'} />}
|
||||||
|
description={<Trans i18nKey={'marketing.whyChooseDescription'} />}
|
||||||
|
textPosition="right"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<WhyItem
|
||||||
|
icon={SmartphoneIcon}
|
||||||
|
titleKey="marketing.whyResponsiveTitle"
|
||||||
|
descKey="marketing.whyResponsiveDesc"
|
||||||
|
/>
|
||||||
|
<WhyItem
|
||||||
|
icon={LockIcon}
|
||||||
|
titleKey="marketing.whySecureTitle"
|
||||||
|
descKey="marketing.whySecureDesc"
|
||||||
|
/>
|
||||||
|
<WhyItem
|
||||||
|
icon={HeadsetIcon}
|
||||||
|
titleKey="marketing.whySupportTitle"
|
||||||
|
descKey="marketing.whySupportDesc"
|
||||||
|
/>
|
||||||
|
<WhyItem
|
||||||
|
icon={ShieldCheckIcon}
|
||||||
|
titleKey="marketing.whyGdprTitle"
|
||||||
|
descKey="marketing.whyGdprDesc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EcosystemShowcase>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<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 dark:text-white xl:text-5xl">
|
||||||
|
<Trans i18nKey={'marketing.howItWorksHeading'} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary-foreground/70 mx-auto mt-4 max-w-2xl text-xl font-medium tracking-tight">
|
||||||
|
<Trans i18nKey={'marketing.howItWorksSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid w-full grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
|
<StepCard
|
||||||
|
step="01"
|
||||||
|
titleKey="marketing.howStep1Title"
|
||||||
|
descKey="marketing.howStep1Desc"
|
||||||
|
/>
|
||||||
|
<StepCard
|
||||||
|
step="02"
|
||||||
|
titleKey="marketing.howStep2Title"
|
||||||
|
descKey="marketing.howStep2Desc"
|
||||||
|
/>
|
||||||
|
<StepCard
|
||||||
|
step="03"
|
||||||
|
titleKey="marketing.howStep3Title"
|
||||||
|
descKey="marketing.howStep3Desc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Section */}
|
||||||
<div className={'container mx-auto'}>
|
<div className={'container mx-auto'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -151,9 +292,13 @@ function Home() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SecondaryHero
|
<SecondaryHero
|
||||||
pill={<Pill label="Start for free">No credit card required.</Pill>}
|
pill={
|
||||||
heading="Fair pricing for all types of businesses"
|
<Pill label={<Trans i18nKey={'marketing.pricingPillLabel'} />}>
|
||||||
subheading="Get started on our free plan and upgrade when you are ready."
|
<Trans i18nKey={'marketing.pricingPillText'} />
|
||||||
|
</Pill>
|
||||||
|
}
|
||||||
|
heading={<Trans i18nKey={'marketing.pricingHeading'} />}
|
||||||
|
subheading={<Trans i18nKey={'marketing.pricingSubheading'} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={'w-full'}>
|
<div className={'w-full'}>
|
||||||
@@ -167,6 +312,37 @@ function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Final CTA */}
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="bg-primary/5 flex flex-col items-center gap-8 rounded-2xl border p-12 text-center lg:p-16">
|
||||||
|
<h2 className="max-w-3xl text-3xl font-medium tracking-tight dark:text-white xl:text-5xl">
|
||||||
|
<Trans i18nKey={'marketing.ctaHeading'} />
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary-foreground/70 max-w-2xl text-lg">
|
||||||
|
<Trans i18nKey={'marketing.ctaDescription'} />
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<CtaButton className="h-12 px-8 text-base">
|
||||||
|
<Link href={'/auth/sign-up'}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Trans i18nKey={'marketing.ctaButtonPrimary'} />
|
||||||
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</CtaButton>
|
||||||
|
<CtaButton variant={'outline'} className="h-12 px-8 text-base">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,3 +377,73 @@ function MainCallToActionButton() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IconFeatureCard(props: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
titleKey: string;
|
||||||
|
descKey: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted/50 flex flex-col gap-3 rounded p-6">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
|
<props.icon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium">
|
||||||
|
<Trans i18nKey={props.titleKey} />
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground max-w-xs text-sm">
|
||||||
|
<Trans i18nKey={props.descKey} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrustItem(props: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2.5">
|
||||||
|
<props.icon className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
<Trans i18nKey={props.label} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhyItem(props: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
titleKey: string;
|
||||||
|
descKey: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<props.icon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-secondary-foreground font-medium">
|
||||||
|
<Trans i18nKey={props.titleKey} />
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans i18nKey={props.descKey} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard(props: { step: string; titleKey: string; descKey: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted/50 relative flex flex-col gap-4 rounded-lg p-6">
|
||||||
|
<span className="text-primary/20 text-6xl font-bold">{props.step}</span>
|
||||||
|
<h3 className="text-secondary-foreground text-xl font-medium">
|
||||||
|
<Trans i18nKey={props.titleKey} />
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey={props.descKey} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
31
apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
Normal file
31
apps/web/app/[locale]/club/[slug]/[...page]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { SiteRenderer } from '@kit/site-builder/components';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ slug: string; page: string[] }> }
|
||||||
|
|
||||||
|
export default async function ClubSubPage({ params }: Props) {
|
||||||
|
const { slug, page: pagePath } = await params;
|
||||||
|
const pageSlug = pagePath.join('/');
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single();
|
||||||
|
if (!account) notFound();
|
||||||
|
|
||||||
|
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
|
||||||
|
if (!settings) notFound();
|
||||||
|
|
||||||
|
const { data: sitePageData } = await supabase.from('site_pages').select('*')
|
||||||
|
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
|
||||||
|
if (!sitePageData) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
|
||||||
|
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export default async function NewsletterSubscribePage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Mail className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Newsletter abonnieren</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">Bleiben Sie über Neuigkeiten informiert.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name (optional)</Label>
|
||||||
|
<Input name="name" placeholder="Max Mustermann" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>E-Mail-Adresse *</Label>
|
||||||
|
<Input name="email" type="email" placeholder="ihre@email.de" required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">Abonnieren</Button>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> }
|
||||||
|
|
||||||
|
export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { token } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-md text-center">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<MailX className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Newsletter abbestellen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{token ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">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>
|
||||||
|
)}
|
||||||
|
<Link href={`/club/${slug}`}>
|
||||||
|
<Button variant="outline" size="sm">← Zurück zur Website</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/web/app/[locale]/club/[slug]/page.tsx
Normal file
34
apps/web/app/[locale]/club/[slug]/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { SiteRenderer } from '@kit/site-builder/components';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export default async function ClubHomePage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Use anon client for public access
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve slug → account
|
||||||
|
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||||
|
if (!account) notFound();
|
||||||
|
|
||||||
|
// Check site is public
|
||||||
|
const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
|
||||||
|
if (!settings) notFound();
|
||||||
|
|
||||||
|
// Get homepage
|
||||||
|
const { data: page } = await supabase.from('site_pages').select('*')
|
||||||
|
.eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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>} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
Normal file
100
apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortalDocumentsPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
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();
|
||||||
|
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
||||||
|
|
||||||
|
// Demo documents (in production: query invoices + cms_files for this member)
|
||||||
|
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: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid': return <Badge variant="default">Bezahlt</Badge>;
|
||||||
|
case 'open': return <Badge variant="secondary">Offen</Badge>;
|
||||||
|
case 'signed': return <Badge variant="outline">Unterschrieben</Badge>;
|
||||||
|
default: return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Rechnung': return <Receipt className="h-5 w-5 text-primary" />;
|
||||||
|
case 'Dokument': return <FileCheck className="h-5 w-5 text-primary" />;
|
||||||
|
default: return <FileText className="h-5 w-5 text-primary" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30">
|
||||||
|
<header className="border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-lg font-bold">Meine Dokumente</h1>
|
||||||
|
</div>
|
||||||
|
<Link href={`/club/${slug}/portal`}>
|
||||||
|
<Button variant="ghost" size="sm">← Zurück zum Portal</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Verfügbare Dokumente</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{String(account.name)} — Dokumente und Rechnungen</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<FileText className="mx-auto h-10 w-10 mb-3" />
|
||||||
|
<p>Keine Dokumente vorhanden</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 className="flex items-center gap-3">
|
||||||
|
{getIcon(doc.type)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{doc.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{doc.type} — {new Date(doc.date).toLocaleDateString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusBadge(doc.status)}
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Download className="h-3 w-3 mr-1" />
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
Normal file
125
apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { UserPlus, Shield, CheckCircle } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortalInvitePage({ params, searchParams }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { token } = await searchParams;
|
||||||
|
|
||||||
|
if (!token) notFound();
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve account
|
||||||
|
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||||
|
if (!account) notFound();
|
||||||
|
|
||||||
|
// Look up invitation
|
||||||
|
const { data: invitation } = await supabase.from('member_portal_invitations')
|
||||||
|
.select('id, email, status, expires_at, member_id')
|
||||||
|
.eq('invite_token', token)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!invitation || invitation.status !== 'pending') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md text-center">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||||
|
<h2 className="text-lg font-bold">Einladung ungültig</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig.
|
||||||
|
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
||||||
|
</p>
|
||||||
|
<Link href={`/club/${slug}`}>
|
||||||
|
<Button variant="outline" className="mt-4">← Zur Website</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expired = new Date(invitation.expires_at) < new Date();
|
||||||
|
if (expired) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md text-center">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Shield className="mx-auto h-10 w-10 text-amber-500 mb-4" />
|
||||||
|
<h2 className="text-lg font-bold">Einladung abgelaufen</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen.
|
||||||
|
Bitte fordern Sie eine neue Einladung an.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<UserPlus className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Einladung zum Mitgliederbereich</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{String(account.name)}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md bg-primary/5 border border-primary/20 p-4 mb-6">
|
||||||
|
<p className="text-sm">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<form className="space-y-4" action={`/api/club/accept-invite`} method="POST">
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<input type="hidden" name="slug" value={slug} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>E-Mail-Adresse</Label>
|
||||||
|
<Input type="email" value={invitation.email} readOnly className="bg-muted" />
|
||||||
|
<p className="text-xs text-muted-foreground">Ihre E-Mail-Adresse wurde vom Verein vorgegeben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Passwort festlegen *</Label>
|
||||||
|
<Input type="password" name="password" placeholder="Mindestens 8 Zeichen" required minLength={8} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Passwort wiederholen *</Label>
|
||||||
|
<Input type="password" name="passwordConfirm" placeholder="Passwort bestätigen" required minLength={8} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Konto erstellen & Einladung annehmen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||||
|
Bereits ein Konto? <Link href={`/club/${slug}/portal`} className="text-primary underline">Anmelden</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/web/app/[locale]/club/[slug]/portal/page.tsx
Normal file
100
apps/web/app/[locale]/club/[slug]/portal/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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 { PortalLoginForm } from '@kit/site-builder/components';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MemberPortalPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||||
|
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
||||||
|
|
||||||
|
// Check if user is already logged in
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Check if this user is a member of this club
|
||||||
|
const { data: member } = await supabase.from('members')
|
||||||
|
.select('id, first_name, last_name, status')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (member) {
|
||||||
|
// Logged in member — show portal dashboard
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30">
|
||||||
|
<header className="border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-lg font-bold">Mitgliederbereich — {String(account.name)}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{String(member.first_name)} {String(member.last_name)}</span>
|
||||||
|
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm">← Website</Button></Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Willkommen, {String(member.first_name)}!</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Link href={`/club/${slug}/portal/profile`}>
|
||||||
|
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<UserCircle className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||||
|
<h3 className="font-semibold">Mein Profil</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Kontaktdaten und Datenschutz</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/club/${slug}/portal/documents`}>
|
||||||
|
<Card className="hover:border-primary transition-colors cursor-pointer">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<FileText className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||||
|
<h3 className="font-semibold">Dokumente</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Rechnungen und Bescheinigungen</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<CreditCard className="mx-auto h-10 w-10 text-primary mb-3" />
|
||||||
|
<h3 className="font-semibold">Mitgliedsausweis</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Digital anzeigen</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not logged in or not a member — show login form
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30">
|
||||||
|
<header className="border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-lg font-bold">Mitgliederbereich</h1>
|
||||||
|
<Link href={`/club/${slug}`}><Button variant="ghost" size="sm">← Zurück zur Website</Button></Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<PortalLoginForm slug={slug} accountName={String(account.name)} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
131
apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortalProfilePage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
|
||||||
|
if (!account) return <div className="p-8 text-center">Organisation nicht gefunden</div>;
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect(`/club/${slug}/portal`);
|
||||||
|
|
||||||
|
// Find member linked to this user
|
||||||
|
const { data: member } = await supabase.from('members')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', account.id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 flex items-center justify-center">
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<Shield className="mx-auto h-10 w-10 text-destructive mb-4" />
|
||||||
|
<h2 className="text-lg font-bold">Kein Mitglied</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft.
|
||||||
|
Bitte wenden Sie sich an Ihren Vereinsadministrator.
|
||||||
|
</p>
|
||||||
|
<Link href={`/club/${slug}/portal`}>
|
||||||
|
<Button variant="outline" className="mt-4">← Zurück</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = member;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30">
|
||||||
|
<header className="border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-lg font-bold">Mein Profil</h1>
|
||||||
|
</div>
|
||||||
|
<Link href={`/club/${slug}/portal`}><Button variant="ghost" size="sm">← Zurück zum Portal</Button></Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-3xl mx-auto py-8 px-6 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<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">
|
||||||
|
<UserCircle className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">{String(m.first_name)} {String(m.last_name)}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />Kontaktdaten</CardTitle></CardHeader>
|
||||||
|
<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"><Label>Nachname</Label><Input defaultValue={String(m.last_name)} readOnly /></div>
|
||||||
|
<div className="space-y-2"><Label>E-Mail</Label><Input defaultValue={String(m.email ?? '')} /></div>
|
||||||
|
<div className="space-y-2"><Label>Telefon</Label><Input defaultValue={String(m.phone ?? '')} /></div>
|
||||||
|
<div className="space-y-2"><Label>Mobil</Label><Input defaultValue={String(m.mobile ?? '')} /></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="flex items-center gap-2"><MapPin className="h-4 w-4" />Adresse</CardTitle></CardHeader>
|
||||||
|
<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"><Label>Hausnummer</Label><Input defaultValue={String(m.house_number ?? '')} /></div>
|
||||||
|
<div className="space-y-2"><Label>PLZ</Label><Input defaultValue={String(m.postal_code ?? '')} /></div>
|
||||||
|
<div className="space-y-2"><Label>Ort</Label><Input defaultValue={String(m.city ?? '')} /></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="flex items-center gap-2"><Shield className="h-4 w-4" />Datenschutz-Einwilligungen</CardTitle></CardHeader>
|
||||||
|
<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_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print },
|
||||||
|
{ key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info },
|
||||||
|
].map(({ key, label, value }) => (
|
||||||
|
<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" />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button>Änderungen speichern</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,235 +1,28 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ArrowLeft, BedDouble } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { createBookingManagementApi } from '@kit/booking-management/api';
|
import { createBookingManagementApi } from '@kit/booking-management/api';
|
||||||
|
import { CreateBookingForm } from '@kit/booking-management/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewBookingPage({ params }: PageProps) {
|
export default async function NewBookingPage({ 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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
const rooms = await api.listRooms(acct.id);
|
||||||
const [rooms, guests] = await Promise.all([
|
|
||||||
api.listRooms(acct.id),
|
|
||||||
api.listGuests(acct.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Buchung">
|
<CmsPageShell account={account} title="Neue Buchung" description="Buchung erstellen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateBookingForm
|
||||||
{/* Header */}
|
accountId={acct.id}
|
||||||
<div className="flex items-center gap-4">
|
account={account}
|
||||||
<Link href={`/home/${account}/bookings`}>
|
rooms={(rooms ?? []).map((r: Record<string, unknown>) => ({
|
||||||
<Button variant="ghost" size="icon">
|
id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0)
|
||||||
<ArrowLeft className="h-4 w-4" />
|
}))}
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Neue Buchung</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Buchung für ein Zimmer erstellen
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="flex flex-col gap-6">
|
|
||||||
{/* Zimmer & Gast */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<BedDouble className="h-5 w-5" />
|
|
||||||
Zimmer & Gast
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Wählen Sie Zimmer und Gast für die Buchung aus
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
htmlFor="room_id"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
Zimmer
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="room_id"
|
|
||||||
name="room_id"
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">Zimmer wählen…</option>
|
|
||||||
{rooms.map((room: Record<string, unknown>) => (
|
|
||||||
<option key={String(room.id)} value={String(room.id)}>
|
|
||||||
{String(room.room_number)} – {String(room.name ?? room.room_type ?? '')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
htmlFor="guest_id"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
Gast
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="guest_id"
|
|
||||||
name="guest_id"
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">Gast wählen…</option>
|
|
||||||
{guests.map((guest: Record<string, unknown>) => (
|
|
||||||
<option key={String(guest.id)} value={String(guest.id)}>
|
|
||||||
{String(guest.last_name)}, {String(guest.first_name)}
|
|
||||||
{guest.email ? ` (${String(guest.email)})` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Aufenthalt */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Aufenthalt</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Reisedaten und Personenanzahl
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="check_in" className="text-sm font-medium">
|
|
||||||
Anreise
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="check_in"
|
|
||||||
name="check_in"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="check_out" className="text-sm font-medium">
|
|
||||||
Abreise
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="check_out"
|
|
||||||
name="check_out"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="adults" className="text-sm font-medium">
|
|
||||||
Erwachsene
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="adults"
|
|
||||||
name="adults"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
defaultValue={1}
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="children" className="text-sm font-medium">
|
|
||||||
Kinder
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="children"
|
|
||||||
name="children"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={0}
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Preis & Notizen */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Preis & Notizen</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5 md:w-1/2">
|
|
||||||
<label htmlFor="total_price" className="text-sm font-medium">
|
|
||||||
Preis (€)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="total_price"
|
|
||||||
name="total_price"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="notes" className="text-sm font-medium">
|
|
||||||
Notizen
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
name="notes"
|
|
||||||
rows={3}
|
|
||||||
placeholder="Zusätzliche Informationen zur Buchung…"
|
|
||||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<Link href={`/home/${account}/bookings`}>
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Buchung erstellen</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +1,18 @@
|
|||||||
import Link from 'next/link';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { CreateCourseForm } from '@kit/course-management/components';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { Label } from '@kit/ui/label';
|
|
||||||
import { Textarea } from '@kit/ui/textarea';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewCoursePage({ params }: PageProps) {
|
export default async function NewCoursePage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neuer Kurs">
|
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">
|
||||||
<div className="flex w-full max-w-3xl flex-col gap-6">
|
<CreateCourseForm accountId={acct.id} account={account} />
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href={`/home/${account}/courses`}>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Zurück
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Neuer Kurs</h1>
|
|
||||||
<p className="text-muted-foreground">Kurs anlegen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="flex flex-col gap-6">
|
|
||||||
{/* Grunddaten */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Grunddaten</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Allgemeine Informationen zum Kurs
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="courseNumber">Kursnummer</Label>
|
|
||||||
<Input
|
|
||||||
id="courseNumber"
|
|
||||||
name="courseNumber"
|
|
||||||
placeholder="z.B. K-2025-001"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="name">Kursname</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
placeholder="z.B. Töpfern für Anfänger"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="description">Beschreibung</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Kursbeschreibung…"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Zeitplan */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Zeitplan</CardTitle>
|
|
||||||
<CardDescription>Beginn und Ende des Kurses</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="startDate">Beginn</Label>
|
|
||||||
<Input
|
|
||||||
id="startDate"
|
|
||||||
name="startDate"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="endDate">Ende</Label>
|
|
||||||
<Input id="endDate" name="endDate" type="date" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Kapazität */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Kapazität</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Teilnehmer und Gebühren
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="capacity">Max. Teilnehmer</Label>
|
|
||||||
<Input
|
|
||||||
id="capacity"
|
|
||||||
name="capacity"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
placeholder="20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="minParticipants">Min. Teilnehmer</Label>
|
|
||||||
<Input
|
|
||||||
id="minParticipants"
|
|
||||||
name="minParticipants"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="fee">Gebühr (€)</Label>
|
|
||||||
<Input
|
|
||||||
id="fee"
|
|
||||||
name="fee"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Zuordnung */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Zuordnung</CardTitle>
|
|
||||||
<CardDescription>Status des Kurses</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
name="status"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
defaultValue="planned"
|
|
||||||
>
|
|
||||||
<option value="planned">Geplant</option>
|
|
||||||
<option value="open">Offen</option>
|
|
||||||
<option value="running">Laufend</option>
|
|
||||||
<option value="completed">Abgeschlossen</option>
|
|
||||||
<option value="cancelled">Abgesagt</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<Link href={`/home/${account}/courses`}>
|
|
||||||
<Button variant="outline" type="button">
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Kurs erstellen</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { GraduationCap, Users, Calendar, TrendingUp, BarChart3 } from 'lucide-react';
|
||||||
GraduationCap,
|
|
||||||
Users,
|
|
||||||
Calendar,
|
|
||||||
TrendingUp,
|
|
||||||
BarChart3,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -13,6 +7,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -22,50 +17,29 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
|||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const { data: acct } = await client
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const stats = await api.getStatistics(acct.id);
|
const stats = await api.getStatistics(acct.id);
|
||||||
|
|
||||||
|
const statusChartData = [
|
||||||
|
{ name: 'Aktiv', value: stats.openCourses },
|
||||||
|
{ name: 'Abgeschlossen', value: stats.completedCourses },
|
||||||
|
{ name: 'Gesamt', value: stats.totalCourses },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Kurs-Statistiken">
|
<CmsPageShell account={account} title="Kurs-Statistiken">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Statistiken</h1>
|
|
||||||
<p className="text-muted-foreground">Übersicht über das Kursangebot</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat Cards */}
|
|
||||||
<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="Kurse gesamt" value={stats.totalCourses} icon={<GraduationCap className="h-5 w-5" />} />
|
||||||
title="Kurse gesamt"
|
<StatsCard title="Aktive Kurse" value={stats.openCourses} icon={<Calendar className="h-5 w-5" />} />
|
||||||
value={stats.totalCourses}
|
<StatsCard title="Teilnehmer" value={stats.totalParticipants} icon={<Users className="h-5 w-5" />} />
|
||||||
icon={<GraduationCap className="h-5 w-5" />}
|
<StatsCard title="Abgeschlossen" value={stats.completedCourses} icon={<TrendingUp className="h-5 w-5" />} />
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Aktive Kurse"
|
|
||||||
value={stats.openCourses}
|
|
||||||
icon={<Calendar className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Teilnehmer gesamt"
|
|
||||||
value={stats.totalParticipants}
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Abgeschlossen"
|
|
||||||
value={stats.completedCourses}
|
|
||||||
icon={<TrendingUp className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart Placeholder */}
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -74,9 +48,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
<StatsBarChart data={statusChartData} />
|
||||||
Diagramm wird hier angezeigt
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -84,16 +56,15 @@ 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" />
|
||||||
Anmeldungen pro Monat
|
Verteilung
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
<StatsPieChart data={statusChartData} />
|
||||||
Diagramm wird hier angezeigt
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,301 +1,18 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
CalendarDays,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { CreateEventForm } from '@kit/event-management/components';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewEventPage({ params }: PageProps) {
|
export default async function NewEventPage({ 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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Veranstaltung">
|
<CmsPageShell account={account} title="Neue Veranstaltung" description="Veranstaltung oder Ferienprogramm anlegen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateEventForm accountId={acct.id} account={account} />
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href={`/home/${account}/events`}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Neue Veranstaltung</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Veranstaltung oder Ferienprogramm anlegen
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="flex flex-col gap-6">
|
|
||||||
{/* Grunddaten */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<CalendarDays className="h-5 w-5" />
|
|
||||||
Grunddaten
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Name und Beschreibung der Veranstaltung
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="name" className="text-sm font-medium">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="z.B. Sommerfest 2025"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="description" className="text-sm font-medium">
|
|
||||||
Beschreibung
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
rows={4}
|
|
||||||
placeholder="Beschreiben Sie die Veranstaltung…"
|
|
||||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Datum & Ort */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
Datum & Ort
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Zeitraum und Veranstaltungsort
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="event_date" className="text-sm font-medium">
|
|
||||||
Veranstaltungsdatum
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="event_date"
|
|
||||||
name="event_date"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="event_time" className="text-sm font-medium">
|
|
||||||
Uhrzeit
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="event_time"
|
|
||||||
name="event_time"
|
|
||||||
type="time"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="end_date" className="text-sm font-medium">
|
|
||||||
Enddatum
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="end_date"
|
|
||||||
name="end_date"
|
|
||||||
type="date"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="location" className="text-sm font-medium">
|
|
||||||
Ort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="location"
|
|
||||||
name="location"
|
|
||||||
type="text"
|
|
||||||
placeholder="z.B. Gemeindehaus, Turnhalle"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Teilnehmer & Kosten */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
Teilnehmer & Kosten
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Kapazität, Alter und Teilnahmegebühr
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="capacity" className="text-sm font-medium">
|
|
||||||
Kapazität
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="capacity"
|
|
||||||
name="capacity"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
placeholder="Max. Teilnehmer"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="min_age" className="text-sm font-medium">
|
|
||||||
Mindestalter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="min_age"
|
|
||||||
name="min_age"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="z.B. 6"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="max_age" className="text-sm font-medium">
|
|
||||||
Höchstalter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="max_age"
|
|
||||||
name="max_age"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="z.B. 16"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="fee" className="text-sm font-medium">
|
|
||||||
Gebühr (€)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="fee"
|
|
||||||
name="fee"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Kontakt */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Phone className="h-5 w-5" />
|
|
||||||
Kontakt
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Ansprechpartner für die Veranstaltung
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="contact_name" className="text-sm font-medium">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact_name"
|
|
||||||
name="contact_name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Vorname Nachname"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="contact_email" className="text-sm font-medium">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact_email"
|
|
||||||
name="contact_email"
|
|
||||||
type="email"
|
|
||||||
placeholder="name@example.de"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="contact_phone" className="text-sm font-medium">
|
|
||||||
Telefon
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact_phone"
|
|
||||||
name="contact_phone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="+49 …"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<Link href={`/home/${account}/events`}>
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Veranstaltung erstellen</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,212 +1,18 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { CreateInvoiceForm } from '@kit/finance/components';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { Label } from '@kit/ui/label';
|
|
||||||
import { Textarea } from '@kit/ui/textarea';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewInvoicePage({ params }: PageProps) {
|
export default async function NewInvoicePage({ 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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neue Rechnung">
|
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateInvoiceForm accountId={acct.id} account={account} />
|
||||||
{/* Back link */}
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={`/home/${account}/finance/invoices`}
|
|
||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
||||||
Zurück zu Rechnungen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="max-w-3xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Neue Rechnung</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Erstellen Sie eine neue Rechnung mit Positionen.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<form className="flex flex-col gap-6">
|
|
||||||
{/* Basic Info */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="invoiceNumber">Rechnungsnummer</Label>
|
|
||||||
<Input
|
|
||||||
id="invoiceNumber"
|
|
||||||
name="invoiceNumber"
|
|
||||||
placeholder="RE-2026-001"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="recipientName">Empfänger</Label>
|
|
||||||
<Input
|
|
||||||
id="recipientName"
|
|
||||||
name="recipientName"
|
|
||||||
placeholder="Max Mustermann"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recipient Address */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="recipientAddress">Empfängeradresse</Label>
|
|
||||||
<Textarea
|
|
||||||
id="recipientAddress"
|
|
||||||
name="recipientAddress"
|
|
||||||
placeholder="Musterstraße 1 12345 Musterstadt"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dates + Tax */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="issueDate">Rechnungsdatum</Label>
|
|
||||||
<Input
|
|
||||||
id="issueDate"
|
|
||||||
name="issueDate"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="dueDate">Fälligkeitsdatum</Label>
|
|
||||||
<Input
|
|
||||||
id="dueDate"
|
|
||||||
name="dueDate"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="taxRate">Steuersatz (%)</Label>
|
|
||||||
<Input
|
|
||||||
id="taxRate"
|
|
||||||
name="taxRate"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
defaultValue="19"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line Items */}
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label>Positionen</Label>
|
|
||||||
<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">
|
|
||||||
Beschreibung
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-right font-medium">Menge</th>
|
|
||||||
<th className="p-3 text-right font-medium">
|
|
||||||
Einzelpreis (€)
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-right font-medium">Gesamt</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[0, 1, 2].map((idx) => (
|
|
||||||
<tr key={idx} className="border-b">
|
|
||||||
<td className="p-2">
|
|
||||||
<Input
|
|
||||||
name={`items[${idx}].description`}
|
|
||||||
placeholder="Leistungsbeschreibung"
|
|
||||||
className="border-0 shadow-none"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="w-24 p-2">
|
|
||||||
<Input
|
|
||||||
name={`items[${idx}].quantity`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
defaultValue="1"
|
|
||||||
className="border-0 text-right shadow-none"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="w-32 p-2">
|
|
||||||
<Input
|
|
||||||
name={`items[${idx}].unitPrice`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
defaultValue="0.00"
|
|
||||||
className="border-0 text-right shadow-none"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="w-32 p-3 text-right text-muted-foreground">
|
|
||||||
0,00 €
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="ml-auto flex w-64 flex-col gap-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Zwischensumme</span>
|
|
||||||
<span>0,00 €</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">MwSt. (19%)</span>
|
|
||||||
<span>0,00 €</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-t pt-1 font-semibold">
|
|
||||||
<span>Gesamt</span>
|
|
||||||
<span>0,00 €</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Link href={`/home/${account}/finance/invoices`}>
|
|
||||||
<Button variant="outline">Abbrechen</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Rechnung erstellen</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { Label } from '@kit/ui/label';
|
|
||||||
|
|
||||||
|
import { CreateSepaBatchForm } from '@kit/finance/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function NewSepaPage({ params }: PageProps) {
|
export default async function NewSepaBatchPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
@@ -34,84 +20,8 @@ export default async function NewSepaPage({ params }: PageProps) {
|
|||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neuer SEPA-Einzug">
|
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateSepaBatchForm accountId={acct.id} account={account} />
|
||||||
{/* Back link */}
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={`/home/${account}/finance/sepa`}
|
|
||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
||||||
Zurück zu SEPA-Lastschriften
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="max-w-2xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Neuer SEPA-Einzug</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Erstellen Sie einen neuen Lastschrifteinzug oder eine Überweisung.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<form className="flex flex-col gap-5">
|
|
||||||
{/* Typ */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="batchType">Typ</Label>
|
|
||||||
<select
|
|
||||||
id="batchType"
|
|
||||||
name="batchType"
|
|
||||||
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"
|
|
||||||
defaultValue="direct_debit"
|
|
||||||
>
|
|
||||||
<option value="direct_debit">Lastschrift</option>
|
|
||||||
<option value="credit_transfer">Überweisung</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Beschreibung */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="description">Beschreibung</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ausführungsdatum */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="executionDate">Ausführungsdatum</Label>
|
|
||||||
<Input
|
|
||||||
id="executionDate"
|
|
||||||
name="executionDate"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PAIN Format Info */}
|
|
||||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
<strong>Hinweis:</strong> Nach dem Erstellen können Sie
|
|
||||||
einzelne Positionen hinzufügen und anschließend die
|
|
||||||
SEPA-XML-Datei generieren.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Link href={`/home/${account}/finance/sepa`}>
|
|
||||||
<Button variant="outline">Abbrechen</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Einzug erstellen</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { EditMemberForm } from '@kit/member-management/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string; memberId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditMemberPage({ params }: Props) {
|
||||||
|
const { account, memberId } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const member = await api.getMember(memberId);
|
||||||
|
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)} bearbeiten`}>
|
||||||
|
<EditMemberForm member={member} account={account} accountId={acct.id} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,173 +1,25 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { User, Mail, Phone, MapPin, CreditCard, Pencil, Ban } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
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 { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { MemberDetailView } from '@kit/member-management/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props {
|
||||||
params: Promise<{ account: string; memberId: string }>;
|
params: Promise<{ account: string; memberId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
export default async function MemberDetailPage({ params }: Props) {
|
||||||
active: 'Aktiv',
|
|
||||||
inactive: 'Inaktiv',
|
|
||||||
pending: 'Ausstehend',
|
|
||||||
cancelled: 'Gekündigt',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
active: 'default',
|
|
||||||
inactive: 'secondary',
|
|
||||||
pending: 'outline',
|
|
||||||
cancelled: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
<span className="text-sm font-medium">{value || '—'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MemberDetailPage({ params }: PageProps) {
|
|
||||||
const { account, memberId } = await params;
|
const { account, memberId } = await params;
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
const member = await api.getMember(memberId);
|
const member = await api.getMember(memberId);
|
||||||
|
|
||||||
if (!member) return <div>Mitglied nicht gefunden</div>;
|
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||||
|
|
||||||
const m = member as Record<string, unknown>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={`${String(m.first_name)} ${String(m.last_name)}`}>
|
<CmsPageShell account={account} title={`${String(member.first_name)} ${String(member.last_name)}`}>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<MemberDetailView member={member} account={account} accountId={acct.id} />
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">
|
|
||||||
{String(m.first_name)} {String(m.last_name)}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<Badge variant={STATUS_VARIANT[String(m.status)] ?? 'secondary'}>
|
|
||||||
{STATUS_LABEL[String(m.status)] ?? String(m.status)}
|
|
||||||
</Badge>
|
|
||||||
{m.member_number ? (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Nr. {String(m.member_number)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline">
|
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Ban className="mr-2 h-4 w-4" />
|
|
||||||
Kündigen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
{/* Persönliche Daten */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
Persönliche Daten
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-4">
|
|
||||||
<DetailRow label="Vorname" value={String(m.first_name ?? '')} />
|
|
||||||
<DetailRow label="Nachname" value={String(m.last_name ?? '')} />
|
|
||||||
<DetailRow
|
|
||||||
label="Geburtsdatum"
|
|
||||||
value={m.date_of_birth ? new Date(String(m.date_of_birth)).toLocaleDateString('de-DE') : ''}
|
|
||||||
/>
|
|
||||||
<DetailRow label="Geschlecht" value={String(m.gender ?? '')} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Kontakt */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
Kontakt
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-4">
|
|
||||||
<DetailRow label="E-Mail" value={String(m.email ?? '')} />
|
|
||||||
<DetailRow label="Telefon" value={String(m.phone ?? '')} />
|
|
||||||
<DetailRow label="Mobil" value={String(m.mobile ?? '')} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Adresse */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<MapPin className="h-4 w-4" />
|
|
||||||
Adresse
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-4">
|
|
||||||
<DetailRow label="Straße" value={`${String(m.street ?? '')} ${String(m.house_number ?? '')}`} />
|
|
||||||
<DetailRow label="PLZ / Ort" value={`${String(m.postal_code ?? '')} ${String(m.city ?? '')}`} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mitgliedschaft */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
Mitgliedschaft
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-4">
|
|
||||||
<DetailRow label="Mitgliedsnr." value={String(m.member_number ?? '')} />
|
|
||||||
<DetailRow label="Status" value={STATUS_LABEL[String(m.status)] ?? String(m.status ?? '')} />
|
|
||||||
<DetailRow
|
|
||||||
label="Eintrittsdatum"
|
|
||||||
value={m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : ''}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="Austrittsdatum"
|
|
||||||
value={m.exit_date ? new Date(String(m.exit_date)).toLocaleDateString('de-DE') : ''}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* SEPA */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<CreditCard className="h-4 w-4" />
|
|
||||||
SEPA-Bankverbindung
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
||||||
<DetailRow label="IBAN" value={String(m.iban ?? '')} />
|
|
||||||
<DetailRow label="BIC" value={String(m.bic ?? '')} />
|
|
||||||
<DetailRow label="Kontoinhaber" value={String(m.account_holder ?? '')} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +1,24 @@
|
|||||||
import { UserCheck, UserX, FileText } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
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 { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'secondary' | 'default' | 'info' | 'destructive'> = {
|
export default async function ApplicationsPage({ params }: Props) {
|
||||||
pending: 'secondary',
|
|
||||||
approved: 'default',
|
|
||||||
rejected: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: 'Ausstehend',
|
|
||||||
approved: 'Genehmigt',
|
|
||||||
rejected: 'Abgelehnt',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ApplicationsPage({ params }: PageProps) {
|
|
||||||
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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const applications = await api.listApplications(acct.id);
|
const applications = await api.listApplications(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Anträge">
|
<CmsPageShell account={account} title="Aufnahmeanträge" description="Mitgliedsanträge bearbeiten">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<ApplicationWorkflow applications={applications} accountId={acct.id} account={account} />
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Mitgliedsanträge</h1>
|
|
||||||
<p className="text-muted-foreground">Eingehende Anträge prüfen und bearbeiten</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{applications.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<FileText className="h-8 w-8" />}
|
|
||||||
title="Keine Anträge"
|
|
||||||
description="Es liegen derzeit keine Mitgliedsanträge vor."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Alle Anträge ({applications.length})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<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">Name</th>
|
|
||||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
|
||||||
<th className="p-3 text-left font-medium">Datum</th>
|
|
||||||
<th className="p-3 text-left font-medium">Status</th>
|
|
||||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{applications.map((app: Record<string, unknown>) => (
|
|
||||||
<tr key={String(app.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">
|
|
||||||
{String(app.last_name ?? '')}, {String(app.first_name ?? '')}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">{String(app.email ?? '—')}</td>
|
|
||||||
<td className="p-3">
|
|
||||||
{app.created_at
|
|
||||||
? new Date(String(app.created_at)).toLocaleDateString('de-DE')
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<Badge variant={STATUS_VARIANT[String(app.status)] ?? 'secondary'}>
|
|
||||||
{STATUS_LABEL[String(app.status)] ?? String(app.status)}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
{String(app.status) === 'pending' && (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button size="sm" variant="default">
|
|
||||||
<UserCheck className="mr-1 h-3 w-3" />
|
|
||||||
Genehmigen
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="destructive">
|
|
||||||
<UserX className="mr-1 h-3 w-3" />
|
|
||||||
Ablehnen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { CreditCard, Download } from 'lucide-react';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MemberCardsPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const result = await api.listMembers(acct.id, { status: 'active', pageSize: 100 });
|
||||||
|
const members = result.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{members.length} aktive Mitglieder</p>
|
||||||
|
<Button disabled>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Alle Ausweise generieren (PDF)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<CreditCard className="h-8 w-8" />}
|
||||||
|
title="Keine aktiven Mitglieder"
|
||||||
|
description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren."
|
||||||
|
actionLabel="Mitglieder verwalten"
|
||||||
|
actionHref={`/home/${account}/members-cms`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{members.map((m: Record<string, unknown>) => (
|
||||||
|
<Card key={String(m.id)}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{String(m.last_name)}, {String(m.first_name)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Nr. {String(m.member_number ?? '—')}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="default">Aktiv</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled>
|
||||||
|
<CreditCard className="mr-1 h-3 w-3" />
|
||||||
|
Ausweis
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>PDF-Generierung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Die PDF-Generierung erfordert die Installation von <code>@react-pdf/renderer</code>.
|
||||||
|
Nach der Installation können Mitgliedsausweise einzeln oder als Stapel erstellt werden.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DepartmentsPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const departments = await api.listDepartments(acct.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten">
|
||||||
|
{departments.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users className="h-8 w-8" />}
|
||||||
|
title="Keine Abteilungen vorhanden"
|
||||||
|
description="Erstellen Sie Ihre erste Abteilung."
|
||||||
|
actionLabel="Neue Abteilung"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">Name</th>
|
||||||
|
<th className="p-3 text-left font-medium">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{departments.map((dept: Record<string, unknown>) => (
|
||||||
|
<tr key={String(dept.id)} className="border-b hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{String(dept.name)}</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,95 +1,24 @@
|
|||||||
import { Euro, Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
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 { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { DuesCategoryManager } from '@kit/member-management/components';
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
import { EmptyState } from '~/components/empty-state';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface Props {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DuesPage({ params }: PageProps) {
|
export default async function DuesPage({ 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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const categories = await api.listDuesCategories(acct.id);
|
const categories = await api.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Beitragskategorien">
|
<CmsPageShell account={account} title="Beitragskategorien" description="Mitgliedsbeiträge verwalten">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<DuesCategoryManager categories={categories} accountId={acct.id} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Beitragskategorien</h1>
|
|
||||||
<p className="text-muted-foreground">Beiträge und Gebühren verwalten</p>
|
|
||||||
</div>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Neue Kategorie
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{categories.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Euro className="h-8 w-8" />}
|
|
||||||
title="Keine Beitragskategorien"
|
|
||||||
description="Legen Sie Ihre erste Beitragskategorie an."
|
|
||||||
actionLabel="Neue Kategorie"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Alle Kategorien ({categories.length})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<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">Name</th>
|
|
||||||
<th className="p-3 text-left font-medium">Beschreibung</th>
|
|
||||||
<th className="p-3 text-right font-medium">Betrag (€)</th>
|
|
||||||
<th className="p-3 text-left font-medium">Intervall</th>
|
|
||||||
<th className="p-3 text-center font-medium">Standard</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{categories.map((cat: Record<string, unknown>) => (
|
|
||||||
<tr key={String(cat.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3 font-medium">{String(cat.name)}</td>
|
|
||||||
<td className="p-3 text-muted-foreground">
|
|
||||||
{String(cat.description ?? '—')}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
{cat.amount != null ? `${Number(cat.amount).toFixed(2)}` : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="p-3">{String(cat.interval ?? '—')}</td>
|
|
||||||
<td className="p-3 text-center">
|
|
||||||
{cat.is_default ? '✓' : '✗'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { MemberImportWizard } from '@kit/member-management/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MemberImportPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren">
|
||||||
|
<MemberImportWizard accountId={acct.id} account={account} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,183 +1,28 @@
|
|||||||
import { UserPlus } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { CreateMemberForm } from '@kit/member-management/components';
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { Label } from '@kit/ui/label';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewMemberPage({ params }: PageProps) {
|
export default async function NewMemberPage({ params }: Props) {
|
||||||
const { account } = await params;
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const duesCategories = await api.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neues Mitglied">
|
<CmsPageShell account={account} title="Neues Mitglied" description="Mitglied manuell anlegen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateMemberForm
|
||||||
<div>
|
accountId={acct.id}
|
||||||
<h1 className="text-2xl font-bold">Neues Mitglied</h1>
|
account={account}
|
||||||
<p className="text-muted-foreground">Mitglied manuell anlegen</p>
|
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
|
||||||
</div>
|
id: String(c.id), name: String(c.name), amount: Number(c.amount ?? 0)
|
||||||
|
}))}
|
||||||
<form className="flex flex-col gap-6">
|
|
||||||
{/* Persönliche Daten */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Persönliche Daten</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="firstName">Vorname</Label>
|
|
||||||
<Input id="firstName" name="firstName" required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lastName">Nachname</Label>
|
|
||||||
<Input id="lastName" name="lastName" required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="dateOfBirth">Geburtsdatum</Label>
|
|
||||||
<Input id="dateOfBirth" name="dateOfBirth" type="date" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="gender">Geschlecht</Label>
|
|
||||||
<select
|
|
||||||
id="gender"
|
|
||||||
name="gender"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">— Bitte wählen —</option>
|
|
||||||
<option value="male">Männlich</option>
|
|
||||||
<option value="female">Weiblich</option>
|
|
||||||
<option value="diverse">Divers</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Kontakt */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Kontakt</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">E-Mail</Label>
|
|
||||||
<Input id="email" name="email" type="email" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="phone">Telefon</Label>
|
|
||||||
<Input id="phone" name="phone" type="tel" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="mobile">Mobil</Label>
|
|
||||||
<Input id="mobile" name="mobile" type="tel" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Adresse */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Adresse</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2 sm:col-span-1">
|
|
||||||
<Label htmlFor="street">Straße</Label>
|
|
||||||
<Input id="street" name="street" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="houseNumber">Hausnummer</Label>
|
|
||||||
<Input id="houseNumber" name="houseNumber" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="postalCode">PLZ</Label>
|
|
||||||
<Input id="postalCode" name="postalCode" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="city">Ort</Label>
|
|
||||||
<Input id="city" name="city" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mitgliedschaft */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Mitgliedschaft</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="memberNumber">Mitgliedsnr.</Label>
|
|
||||||
<Input id="memberNumber" name="memberNumber" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
name="status"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<option value="active">Aktiv</option>
|
|
||||||
<option value="inactive">Inaktiv</option>
|
|
||||||
<option value="pending">Ausstehend</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="entryDate">Eintrittsdatum</Label>
|
|
||||||
<Input id="entryDate" name="entryDate" type="date" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* SEPA */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>SEPA-Bankverbindung</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="iban">IBAN</Label>
|
|
||||||
<Input id="iban" name="iban" placeholder="DE89 3704 0044 0532 0130 00" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="bic">BIC</Label>
|
|
||||||
<Input id="bic" name="bic" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="accountHolder">Kontoinhaber</Label>
|
|
||||||
<Input id="accountHolder" name="accountHolder" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notizen */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Notizen</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<textarea
|
|
||||||
name="notes"
|
|
||||||
rows={4}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
placeholder="Zusätzliche Anmerkungen…"
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" size="lg">
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
Mitglied erstellen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,42 @@
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { MembersDataTable } from '@kit/member-management/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface MembersPageProps {
|
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 MembersPage({ params, searchParams }: MembersPageProps) {
|
export default async function MembersPage({ 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 { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
const { data: accountData } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!accountData) return <div>Konto nicht gefunden</div>;
|
|
||||||
|
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
const result = await api.listMembers(accountData.id, {
|
const result = await api.listMembers(acct.id, {
|
||||||
search: search.q as string,
|
search: search.q as string,
|
||||||
status: search.status as string,
|
status: search.status as string,
|
||||||
page,
|
page,
|
||||||
|
pageSize: 25,
|
||||||
});
|
});
|
||||||
|
const duesCategories = await api.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<CmsPageShell account={account} title="Mitglieder" description={`${result.total} Mitglieder`}>
|
||||||
<div className="flex items-center justify-between">
|
<MembersDataTable
|
||||||
<div>
|
data={result.data}
|
||||||
<h1 className="text-2xl font-bold">Mitglieder</h1>
|
total={result.total}
|
||||||
<p className="text-muted-foreground">{result.total} Mitglieder</p>
|
page={page}
|
||||||
</div>
|
pageSize={25}
|
||||||
</div>
|
account={account}
|
||||||
|
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
|
||||||
<div className="rounded-md border">
|
id: String(c.id), name: String(c.name),
|
||||||
<table className="w-full text-sm">
|
}))}
|
||||||
<thead>
|
/>
|
||||||
<tr className="border-b bg-muted/50">
|
</CmsPageShell>
|
||||||
<th className="p-3 text-left">Nr.</th>
|
|
||||||
<th className="p-3 text-left">Name</th>
|
|
||||||
<th className="p-3 text-left">E-Mail</th>
|
|
||||||
<th className="p-3 text-left">Ort</th>
|
|
||||||
<th className="p-3 text-left">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.data.map((member: Record<string, unknown>) => (
|
|
||||||
<tr key={String(member.id)} className="border-b hover:bg-muted/30">
|
|
||||||
<td className="p-3">{String(member.member_number ?? '—')}</td>
|
|
||||||
<td className="p-3 font-medium">{String(member.last_name)}, {String(member.first_name)}</td>
|
|
||||||
<td className="p-3">{String(member.email ?? '—')}</td>
|
|
||||||
<td className="p-3">{String(member.postal_code ?? '')} {String(member.city ?? '')}</td>
|
|
||||||
<td className="p-3">{String(member.status)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createMemberManagementApi } from '@kit/member-management/api';
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ account: string }>;
|
params: Promise<{ account: string }>;
|
||||||
@@ -27,49 +28,33 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
|||||||
const api = createMemberManagementApi(client);
|
const api = createMemberManagementApi(client);
|
||||||
const stats = await api.getMemberStatistics(acct.id);
|
const stats = await api.getMemberStatistics(acct.id);
|
||||||
|
|
||||||
|
const statusChartData = [
|
||||||
|
{ name: 'Aktiv', value: stats.active ?? 0 },
|
||||||
|
{ name: 'Inaktiv', value: stats.inactive ?? 0 },
|
||||||
|
{ name: 'Ausstehend', value: stats.pending ?? 0 },
|
||||||
|
{ name: 'Ausgetreten', value: stats.resigned ?? 0 },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Mitglieder-Statistiken">
|
<CmsPageShell account={account} title="Mitglieder-Statistiken">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Mitglieder-Statistiken</h1>
|
|
||||||
<p className="text-muted-foreground">Übersicht über Ihre Mitglieder</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" value={stats.total ?? 0} icon={<Users className="h-5 w-5" />} />
|
||||||
title="Gesamt"
|
<StatsCard title="Aktiv" value={stats.active ?? 0} icon={<UserCheck className="h-5 w-5" />} />
|
||||||
value={stats.total ?? 0}
|
<StatsCard title="Inaktiv" value={stats.inactive ?? 0} icon={<UserMinus className="h-5 w-5" />} />
|
||||||
icon={<Users className="h-5 w-5" />}
|
<StatsCard title="Ausstehend" value={stats.pending ?? 0} icon={<Clock className="h-5 w-5" />} />
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Aktiv"
|
|
||||||
value={stats.active ?? 0}
|
|
||||||
icon={<UserCheck className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Inaktiv"
|
|
||||||
value={stats.inactive ?? 0}
|
|
||||||
icon={<UserMinus className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Ausstehend"
|
|
||||||
value={stats.pending ?? 0}
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart Placeholders */}
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<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" />
|
||||||
Mitgliederentwicklung
|
Mitglieder nach Status
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
<StatsBarChart data={statusChartData} />
|
||||||
Diagramm wird hier angezeigt
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -77,16 +62,15 @@ export default async function MemberStatisticsPage({ 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" />
|
||||||
Eintritte / Austritte pro Monat
|
Verteilung
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-64 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
<StatsPieChart data={statusChartData} />
|
||||||
Diagramm wird hier angezeigt
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +1,18 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
import { CreateNewsletterForm } from '@kit/newsletter/components';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { Label } from '@kit/ui/label';
|
|
||||||
import { Textarea } from '@kit/ui/textarea';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
interface PageProps {
|
interface Props { params: Promise<{ account: string }> }
|
||||||
params: Promise<{ account: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewNewsletterPage({ params }: PageProps) {
|
export default async function NewNewsletterPage({ 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 { data: acct } = await client
|
|
||||||
.from('accounts')
|
|
||||||
.select('id')
|
|
||||||
.eq('slug', account)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title="Neuer Newsletter">
|
<CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen">
|
||||||
<div className="flex w-full flex-col gap-6">
|
<CreateNewsletterForm accountId={acct.id} account={account} />
|
||||||
{/* Back link */}
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={`/home/${account}/newsletter`}
|
|
||||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
||||||
Zurück zu Newsletter
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="max-w-2xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Neuer Newsletter</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Erstellen Sie eine neue Newsletter-Kampagne.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<form className="flex flex-col gap-5">
|
|
||||||
{/* Betreff */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="subject">Betreff</Label>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
name="subject"
|
|
||||||
placeholder="z.B. Monatliche Vereinsnachrichten März 2026"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body HTML */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="bodyHtml">Inhalt (HTML)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="bodyHtml"
|
|
||||||
name="bodyHtml"
|
|
||||||
placeholder="<h1>Hallo {{first_name}},</h1> <p>Neuigkeiten aus dem Verein...</p>"
|
|
||||||
rows={10}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Verwenden Sie {'{{first_name}}'}, {'{{name}}'} und{' '}
|
|
||||||
{'{{email}}'} als Platzhalter für die Personalisierung.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empfänger Info */}
|
|
||||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
<strong>Empfänger-Auswahl:</strong> Nach dem Erstellen können
|
|
||||||
Sie die Empfänger aus Ihrer Mitgliederliste auswählen. Es
|
|
||||||
werden nur Mitglieder mit hinterlegter E-Mail-Adresse
|
|
||||||
berücksichtigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vorlage Auswahl */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="templateId">
|
|
||||||
Vorlage (optional)
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="templateId"
|
|
||||||
name="templateId"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="">Keine Vorlage</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Link href={`/home/${account}/newsletter`}>
|
|
||||||
<Button variant="outline">Abbrechen</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="submit">Newsletter erstellen</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||||
|
import { SiteEditor } from '@kit/site-builder/components';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ account: string; pageId: string }> }
|
||||||
|
|
||||||
|
export default async function EditPageRoute({ params }: Props) {
|
||||||
|
const { account, pageId } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createSiteBuilderApi(client);
|
||||||
|
const page = await api.getPage(pageId);
|
||||||
|
if (!page) return <div>Seite nicht gefunden</div>;
|
||||||
|
|
||||||
|
return <SiteEditor pageId={pageId} accountId={acct.id} initialData={(page.puck_data ?? {}) as Record<string, unknown>} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { CreatePageForm } from '@kit/site-builder/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NewSitePage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen">
|
||||||
|
<CreatePageForm accountId={acct.id} account={account} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/web/app/[locale]/home/[account]/site-builder/page.tsx
Normal file
95
apps/web/app/[locale]/home/[account]/site-builder/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ account: string }> }
|
||||||
|
|
||||||
|
export default async function SiteBuilderDashboard({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createSiteBuilderApi(client);
|
||||||
|
const pages = await api.listPages(acct.id);
|
||||||
|
const settings = await api.getSiteSettings(acct.id);
|
||||||
|
const posts = await api.listPosts(acct.id);
|
||||||
|
|
||||||
|
const publishedCount = pages.filter((p: any) => p.is_published).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={`/home/${account}/site-builder/settings`}>
|
||||||
|
<Button variant="outline" size="sm"><Settings className="mr-2 h-4 w-4" />Einstellungen</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/home/${account}/site-builder/posts`}>
|
||||||
|
<Button variant="outline" size="sm"><FileText className="mr-2 h-4 w-4" />Beiträge ({posts.length})</Button>
|
||||||
|
</Link>
|
||||||
|
{settings?.is_public && (
|
||||||
|
<a href={`/club/${account}`} target="_blank" rel="noopener">
|
||||||
|
<Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link href={`/home/${account}/site-builder/new`}>
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4" />Neue Seite</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Seiten</p><p className="text-2xl font-bold">{pages.length}</p></CardContent></Card>
|
||||||
|
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Veröffentlicht</p><p className="text-2xl font-bold">{publishedCount}</p></CardContent></Card>
|
||||||
|
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Status</p><p className="text-2xl font-bold">{settings?.is_public ? '🟢 Online' : '🔴 Offline'}</p></CardContent></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Globe className="h-8 w-8" />}
|
||||||
|
title="Noch keine Seiten"
|
||||||
|
description="Erstellen Sie Ihre erste Seite mit dem visuellen Editor."
|
||||||
|
actionLabel="Erste Seite erstellen"
|
||||||
|
actionHref={`/home/${account}/site-builder/new`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">Titel</th>
|
||||||
|
<th className="p-3 text-left font-medium">URL</th>
|
||||||
|
<th className="p-3 text-left font-medium">Status</th>
|
||||||
|
<th className="p-3 text-left font-medium">Startseite</th>
|
||||||
|
<th className="p-3 text-left font-medium">Aktualisiert</th>
|
||||||
|
<th className="p-3 text-left font-medium">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{pages.map((page: Record<string, unknown>) => (
|
||||||
|
<tr key={String(page.id)} className="border-b hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{String(page.title)}</td>
|
||||||
|
<td className="p-3 text-muted-foreground font-mono text-xs">/{String(page.slug)}</td>
|
||||||
|
<td className="p-3"><Badge variant={page.is_published ? 'default' : 'secondary'}>{page.is_published ? 'Veröffentlicht' : 'Entwurf'}</Badge></td>
|
||||||
|
<td className="p-3">{page.is_homepage ? '⭐' : '—'}</td>
|
||||||
|
<td className="p-3 text-xs text-muted-foreground">{page.updated_at ? new Date(String(page.updated_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Link href={`/home/${account}/site-builder/${String(page.id)}/edit`}>
|
||||||
|
<Button size="sm" variant="outline">Bearbeiten</Button>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { CreatePostForm } from '@kit/site-builder/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ account: string }> }
|
||||||
|
|
||||||
|
export default async function NewPostPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Neuer Beitrag" description="Beitrag erstellen">
|
||||||
|
<CreatePostForm accountId={acct.id} account={account} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||||
|
import { Card, CardContent } from '@kit/ui/card';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
import { EmptyState } from '~/components/empty-state';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ account: string }> }
|
||||||
|
|
||||||
|
export default async function PostsManagerPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createSiteBuilderApi(client);
|
||||||
|
const posts = await api.listPosts(acct.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Beiträge" description="Neuigkeiten und Artikel verwalten">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4" />Neuer Beitrag</Button>
|
||||||
|
</div>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Beiträge" description="Erstellen Sie Ihren ersten Beitrag." actionLabel="Beitrag erstellen" />
|
||||||
|
) : (
|
||||||
|
<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">Titel</th>
|
||||||
|
<th className="p-3 text-left">Status</th>
|
||||||
|
<th className="p-3 text-left">Erstellt</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.map((post: Record<string, unknown>) => (
|
||||||
|
<tr key={String(post.id)} className="border-b hover:bg-muted/30">
|
||||||
|
<td className="p-3 font-medium">{String(post.title)}</td>
|
||||||
|
<td className="p-3"><Badge variant={post.status === 'published' ? 'default' : 'secondary'}>{String(post.status)}</Badge></td>
|
||||||
|
<td className="p-3 text-xs text-muted-foreground">{post.created_at ? new Date(String(post.created_at)).toLocaleDateString('de-DE') : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createSiteBuilderApi } from '@kit/site-builder/api';
|
||||||
|
import { SiteSettingsForm } from '@kit/site-builder/components';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props { params: Promise<{ account: string }> }
|
||||||
|
|
||||||
|
export default async function SiteSettingsPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
|
||||||
|
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||||
|
|
||||||
|
const api = createSiteBuilderApi(client);
|
||||||
|
const settings = await api.getSiteSettings(acct.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell account={account} title="Website-Einstellungen" description="Design und Kontaktdaten">
|
||||||
|
<SiteSettingsForm accountId={acct.id} account={account} settings={settings} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/web/app/api/club/accept-invite/route.ts
Normal file
75
apps/web/app/api/club/accept-invite/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const token = formData.get('token') as string;
|
||||||
|
const slug = formData.get('slug') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
|
if (!token || !password || password.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'Ungültige Eingabe' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use service role to create user + link member
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SECRET_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Get invitation
|
||||||
|
const { data: invitation, error: invError } = await supabase
|
||||||
|
.from('member_portal_invitations')
|
||||||
|
.select('id, email, member_id, account_id, status, expires_at')
|
||||||
|
.eq('invite_token', token)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (invError || !invitation || invitation.status !== 'pending') {
|
||||||
|
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=invalid`, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(invitation.expires_at) < new Date()) {
|
||||||
|
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=expired`, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create auth user
|
||||||
|
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
|
||||||
|
email: invitation.email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { invited_via: 'member_portal' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
// User might already exist — try to find them
|
||||||
|
const { data: existingUsers } = await supabase.auth.admin.listUsers();
|
||||||
|
const existing = existingUsers?.users?.find(u => u.email === invitation.email);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Link existing user to member
|
||||||
|
await supabase.from('members').update({ user_id: existing.id }).eq('id', invitation.member_id);
|
||||||
|
await supabase.from('member_portal_invitations').update({ status: 'accepted', accepted_at: new Date().toISOString() }).eq('id', invitation.id);
|
||||||
|
return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[accept-invite] Auth error:', authError.message);
|
||||||
|
return NextResponse.redirect(new URL(`/club/${slug}/portal/invite?token=${token}&error=auth`, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Link member to user
|
||||||
|
await supabase.from('members').update({ user_id: authData.user.id }).eq('id', invitation.member_id);
|
||||||
|
|
||||||
|
// 4. Mark invitation as accepted
|
||||||
|
await supabase.from('member_portal_invitations').update({
|
||||||
|
status: 'accepted',
|
||||||
|
accepted_at: new Date().toISOString(),
|
||||||
|
}).eq('id', invitation.id);
|
||||||
|
|
||||||
|
// 5. Redirect to portal login
|
||||||
|
return NextResponse.redirect(new URL(`/club/${slug}/portal`, request.url));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[accept-invite] Error:', err);
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/web/app/api/club/contact/route.ts
Normal file
40
apps/web/app/api/club/contact/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { recipientEmail, name, email, subject, message } = body;
|
||||||
|
|
||||||
|
if (!email || !message) {
|
||||||
|
return NextResponse.json({ error: 'E-Mail und Nachricht sind erforderlich' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production: use @kit/mailers to send the email
|
||||||
|
// For now: log and return success
|
||||||
|
console.log('[contact] Form submission:', {
|
||||||
|
to: recipientEmail || 'admin',
|
||||||
|
from: email,
|
||||||
|
name,
|
||||||
|
subject: subject || 'Kontaktanfrage',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Wire to @kit/mailers
|
||||||
|
// const mailer = await getMailer();
|
||||||
|
// await mailer.sendMail({
|
||||||
|
// to: recipientEmail,
|
||||||
|
// from: email,
|
||||||
|
// subject: subject || 'Kontaktanfrage von der Website',
|
||||||
|
// text: `Name: ${name}\nE-Mail: ${email}\n\n${message}`,
|
||||||
|
// });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Nachricht gesendet' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[contact] Error:', err);
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/web/app/api/club/newsletter/route.ts
Normal file
42
apps/web/app/api/club/newsletter/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { accountId, email, name } = body;
|
||||||
|
|
||||||
|
if (!accountId || !email) {
|
||||||
|
return NextResponse.json({ error: 'accountId und email sind erforderlich' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return NextResponse.json({ error: 'Ungültige E-Mail-Adresse' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const { error } = await supabase.from('newsletter_subscriptions').upsert({
|
||||||
|
account_id: accountId,
|
||||||
|
email,
|
||||||
|
name: name || null,
|
||||||
|
confirmation_token: token,
|
||||||
|
is_active: true,
|
||||||
|
}, { onConflict: 'account_id,email' });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[newsletter] Subscription error:', error.message);
|
||||||
|
return NextResponse.json({ error: 'Anmeldung fehlgeschlagen' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Erfolgreich angemeldet' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[newsletter] Error:', err);
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/web/components/stats-charts.tsx
Normal file
71
apps/web/components/stats-charts.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
const COLORS = ['#0d9488', '#14b8a6', '#2dd4bf', '#5eead4', '#99f6e4', '#ccfbf1'];
|
||||||
|
|
||||||
|
interface BarChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsBarChart({ data, title }: { data: BarChartData[]; title?: string }) {
|
||||||
|
if (data.length === 0 || data.every(d => d.value === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Noch keine Daten vorhanden
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64">
|
||||||
|
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis dataKey="name" className="text-xs" />
|
||||||
|
<YAxis className="text-xs" />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" fill="var(--primary, #0d9488)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsPieChart({ data, title }: { data: PieChartData[]; title?: string }) {
|
||||||
|
const filtered = data.filter(d => d.value > 0);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Noch keine Daten vorhanden
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64">
|
||||||
|
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={filtered} cx="50%" cy="50%" innerRadius={50} outerRadius={80} dataKey="value" label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}>
|
||||||
|
{filtered.map((_, i) => (
|
||||||
|
<Cell key={`cell-${i}`} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Legend />
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ const FeatureFlagsSchema = z.object({
|
|||||||
enableDocumentGeneration: z.boolean().default(true),
|
enableDocumentGeneration: z.boolean().default(true),
|
||||||
enableNewsletter: z.boolean().default(true),
|
enableNewsletter: z.boolean().default(true),
|
||||||
enableGdprCompliance: z.boolean().default(true),
|
enableGdprCompliance: z.boolean().default(true),
|
||||||
|
enableSiteBuilder: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
const featuresFlagConfig = FeatureFlagsSchema.parse({
|
||||||
@@ -132,6 +133,10 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({
|
|||||||
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
|
process.env.NEXT_PUBLIC_ENABLE_GDPR_COMPLIANCE,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
enableSiteBuilder: getBoolean(
|
||||||
|
process.env.NEXT_PUBLIC_ENABLE_SITE_BUILDER,
|
||||||
|
true,
|
||||||
|
),
|
||||||
} satisfies z.output<typeof FeatureFlagsSchema>);
|
} satisfies z.output<typeof FeatureFlagsSchema>);
|
||||||
|
|
||||||
export default featuresFlagConfig;
|
export default featuresFlagConfig;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const PathsSchema = z.object({
|
|||||||
accountFinance: z.string().min(1),
|
accountFinance: z.string().min(1),
|
||||||
accountDocuments: z.string().min(1),
|
accountDocuments: z.string().min(1),
|
||||||
accountNewsletter: z.string().min(1),
|
accountNewsletter: z.string().min(1),
|
||||||
|
accountSiteBuilder: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ const pathsConfig = PathsSchema.parse({
|
|||||||
accountFinance: `/home/[account]/finance`,
|
accountFinance: `/home/[account]/finance`,
|
||||||
accountDocuments: `/home/[account]/documents`,
|
accountDocuments: `/home/[account]/documents`,
|
||||||
accountNewsletter: `/home/[account]/newsletter`,
|
accountNewsletter: `/home/[account]/newsletter`,
|
||||||
|
accountSiteBuilder: `/home/[account]/site-builder`,
|
||||||
},
|
},
|
||||||
} satisfies z.output<typeof PathsSchema>);
|
} satisfies z.output<typeof PathsSchema>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
CreditCard, LayoutDashboard, Settings, Users, Database,
|
CreditCard, LayoutDashboard, Settings, Users, Database,
|
||||||
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
|
UserCheck, GraduationCap, Hotel, Calendar, Wallet,
|
||||||
FileText, Mail,
|
FileText, Mail, Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||||
@@ -75,6 +75,11 @@ const getRoutes = (account: string) => [
|
|||||||
Icon: <Mail className={iconClasses} />,
|
Icon: <Mail className={iconClasses} />,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
{
|
||||||
|
label: 'common.routes.siteBuilder',
|
||||||
|
path: createPath(`/home/[account]/site-builder`, account),
|
||||||
|
Icon: <Globe className={iconClasses} />,
|
||||||
|
},
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -74,7 +74,8 @@
|
|||||||
"finance": "Finanzen",
|
"finance": "Finanzen",
|
||||||
"documents": "Dokumente",
|
"documents": "Dokumente",
|
||||||
"newsletter": "Newsletter",
|
"newsletter": "Newsletter",
|
||||||
"events": "Veranstaltungen"
|
"events": "Veranstaltungen",
|
||||||
|
"siteBuilder": "Website"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": {
|
"owner": {
|
||||||
|
|||||||
@@ -41,6 +41,78 @@
|
|||||||
"contactError": "Fehler beim Senden Ihrer Nachricht",
|
"contactError": "Fehler beim Senden Ihrer Nachricht",
|
||||||
"contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich",
|
"contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich",
|
||||||
"contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
|
"contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
|
||||||
"footerDescription": "Hier können Sie eine Beschreibung Ihres Unternehmens oder Produkts einfügen",
|
"footerDescription": "Die All-in-One-Verwaltungsplattform für Vereine, Clubs und Organisationen. Entwickelt von Com.BISS GmbH.",
|
||||||
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten."
|
"copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten.",
|
||||||
|
|
||||||
|
"heroPill": "Die nächste Generation der Vereinsverwaltung",
|
||||||
|
"heroTitle": "Verwalten Sie Ihre Organisation. Einfach und effizient.",
|
||||||
|
"heroSubtitle": "MyEasyCMS ist die All-in-One-Plattform für Vereine, Clubs und Organisationen. Verwalten Sie Mitglieder, Kurse, Veranstaltungen, Finanzen und mehr — alles an einem Ort.",
|
||||||
|
|
||||||
|
"trustedBy": "Vertraut von Vereinen und Clubs in ganz Deutschland",
|
||||||
|
"trustAssociations": "Vereine",
|
||||||
|
"trustSchools": "Bildungseinrichtungen",
|
||||||
|
"trustClubs": "Sport- & Angelvereine",
|
||||||
|
"trustOrganizations": "Gemeinnützige Organisationen",
|
||||||
|
|
||||||
|
"featuresHeading": "Alles, was Ihre Organisation braucht",
|
||||||
|
"featuresSubheading": "Von der Mitgliederverwaltung bis zur Finanzbuchhaltung — alle Werkzeuge in einer modernen, benutzerfreundlichen Plattform.",
|
||||||
|
"featuresLabel": "Kernmodule",
|
||||||
|
|
||||||
|
"featureMembersTitle": "Mitgliederverwaltung",
|
||||||
|
"featureMembersDesc": "Verwalten Sie alle Mitglieder mit Abteilungen, Beitragsverfolgung, Mitgliedsausweisen, Anträgen und detaillierten Statistiken.",
|
||||||
|
"featureCoursesTitle": "Kursverwaltung",
|
||||||
|
"featureCoursesDesc": "Organisieren Sie Kurse mit Terminplanung, Dozentenzuweisung, Anwesenheitsverfolgung, Kategorien und Standorten.",
|
||||||
|
"featureBookingsTitle": "Raumbuchungen",
|
||||||
|
"featureBookingsDesc": "Buchen Sie Räume und Ressourcen mit einem visuellen Kalender, verwalten Sie Gäste und prüfen Sie die Verfügbarkeit.",
|
||||||
|
"featureEventsTitle": "Veranstaltungsverwaltung",
|
||||||
|
"featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung.",
|
||||||
|
"featureFinanceTitle": "Finanzen & Abrechnung",
|
||||||
|
"featureFinanceDesc": "Erstellen Sie Rechnungen, verwalten Sie Zahlungen und SEPA-Lastschrifteinzüge — behalten Sie Ihre Finanzen mühelos im Griff.",
|
||||||
|
"featureNewsletterTitle": "Newsletter",
|
||||||
|
"featureNewsletterDesc": "Erstellen und versenden Sie professionelle Newsletter mit Vorlagen. Halten Sie Ihre Mitglieder informiert.",
|
||||||
|
|
||||||
|
"showcaseHeading": "Ein leistungsstarkes Dashboard auf einen Blick",
|
||||||
|
"showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation mit unserem intuitiven Dashboard. Greifen Sie auf alles zu — Mitglieder, Kurse, Veranstaltungen und Finanzen — von einer zentralen Stelle aus.",
|
||||||
|
|
||||||
|
"additionalFeaturesHeading": "Und es gibt noch mehr",
|
||||||
|
"additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Arbeit Ihrer Organisation vereinfachen.",
|
||||||
|
"additionalFeaturesLabel": "Weitere Funktionen",
|
||||||
|
|
||||||
|
"featureDocumentsTitle": "Dokumentenverwaltung",
|
||||||
|
"featureDocumentsDesc": "Erstellen Sie Dokumente aus Vorlagen, verwalten Sie Dateien und halten Sie alle wichtigen Unterlagen organisiert.",
|
||||||
|
"featureSiteBuilderTitle": "Website-Baukasten",
|
||||||
|
"featureSiteBuilderDesc": "Erstellen und verwalten Sie die Website Ihrer Organisation ohne Programmierkenntnisse. Aktualisieren Sie Inhalte ganz einfach.",
|
||||||
|
"featureModulesTitle": "Individuelle Module",
|
||||||
|
"featureModulesDesc": "Erweitern Sie die Plattform mit maßgeschneiderten Modulen für Ihre spezifischen Anforderungen. Importieren Sie Daten und passen Sie Einstellungen an.",
|
||||||
|
|
||||||
|
"whyChooseHeading": "Warum Organisationen MyEasyCMS wählen",
|
||||||
|
"whyChooseDescription": "Entwickelt mit über 20 Jahren Erfahrung im Dienste von Vereinen, Clubs und gemeinnützigen Organisationen in ganz Deutschland.",
|
||||||
|
"whyResponsiveTitle": "Mobilfreundlich",
|
||||||
|
"whyResponsiveDesc": "Greifen Sie von jedem Gerät auf Ihre Daten zu. Unser responsives Design funktioniert perfekt auf Desktop, Tablet und Smartphone.",
|
||||||
|
"whySecureTitle": "Sicher & Zuverlässig",
|
||||||
|
"whySecureDesc": "Ihre Daten sind mit erstklassiger Sicherheit geschützt. Regelmäßige Backups stellen sicher, dass nichts verloren geht.",
|
||||||
|
"whySupportTitle": "Persönlicher Support",
|
||||||
|
"whySupportDesc": "Erhalten Sie direkten, persönlichen Support von unserem Team. Wir sprechen Ihre Sprache und verstehen Ihre Bedürfnisse.",
|
||||||
|
"whyGdprTitle": "DSGVO-konform",
|
||||||
|
"whyGdprDesc": "Vollständig konform mit der europäischen Datenschutz-Grundverordnung. Die Daten Ihrer Mitglieder werden sorgfältig behandelt.",
|
||||||
|
|
||||||
|
"howItWorksHeading": "In drei einfachen Schritten loslegen",
|
||||||
|
"howItWorksSubheading": "Die Einrichtung Ihrer Organisation auf MyEasyCMS dauert nur wenige Minuten.",
|
||||||
|
"howStep1Title": "Konto erstellen",
|
||||||
|
"howStep1Desc": "Registrieren Sie sich kostenlos und richten Sie Ihr Organisationsprofil ein. Keine Kreditkarte erforderlich.",
|
||||||
|
"howStep2Title": "Module konfigurieren",
|
||||||
|
"howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Kurse, Veranstaltungen, Finanzen — und passen Sie diese an Ihren Workflow an.",
|
||||||
|
"howStep3Title": "Team einladen",
|
||||||
|
"howStep3Desc": "Fügen Sie Teammitglieder mit verschiedenen Rollen und Berechtigungen hinzu. Verwalten Sie Ihre Organisation gemeinsam.",
|
||||||
|
|
||||||
|
"pricingPillLabel": "Kostenlos starten",
|
||||||
|
"pricingPillText": "Keine Kreditkarte erforderlich.",
|
||||||
|
"pricingHeading": "Faire Preise für alle Arten von Organisationen",
|
||||||
|
"pricingSubheading": "Starten Sie mit unserem kostenlosen Tarif und upgraden Sie, wenn Sie bereit sind.",
|
||||||
|
|
||||||
|
"ctaHeading": "Bereit, die Verwaltung Ihrer Organisation zu vereinfachen?",
|
||||||
|
"ctaDescription": "Schließen Sie sich hunderten von Vereinen, Clubs und Organisationen an, die MyEasyCMS bereits nutzen.",
|
||||||
|
"ctaButtonPrimary": "Jetzt kostenlos starten",
|
||||||
|
"ctaButtonSecondary": "Kontakt aufnehmen",
|
||||||
|
"ctaNote": "Keine Kreditkarte erforderlich. Kostenloser Tarif verfügbar."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"courses": "Courses",
|
"courses": "Courses",
|
||||||
"bookings": "Bookings",
|
"bookings": "Bookings",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
|
"siteBuilder": "Website",
|
||||||
"finance": "Finance",
|
"finance": "Finance",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"newsletter": "Newsletter"
|
"newsletter": "Newsletter"
|
||||||
|
|||||||
@@ -41,6 +41,78 @@
|
|||||||
"contactError": "An error occurred while sending your message",
|
"contactError": "An error occurred while sending your message",
|
||||||
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
|
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
|
||||||
"contactErrorDescription": "An error occurred while sending your message. Please try again later",
|
"contactErrorDescription": "An error occurred while sending your message. Please try again later",
|
||||||
"footerDescription": "Here you can add a description about your company or product",
|
"footerDescription": "The all-in-one management platform for associations, clubs, and organizations. Built by Com.BISS GmbH.",
|
||||||
"copyright": "© Copyright {year} {product}. All Rights Reserved."
|
"copyright": "© Copyright {year} {product}. All Rights Reserved.",
|
||||||
|
|
||||||
|
"heroPill": "The next generation of association management",
|
||||||
|
"heroTitle": "Manage your organization. Simply and efficiently.",
|
||||||
|
"heroSubtitle": "MyEasyCMS is the all-in-one platform for associations, clubs, and organizations. Manage members, courses, events, finances, and more — all from one place.",
|
||||||
|
|
||||||
|
"trustedBy": "Trusted by associations and clubs across Germany",
|
||||||
|
"trustAssociations": "Associations",
|
||||||
|
"trustSchools": "Educational Institutions",
|
||||||
|
"trustClubs": "Sports & Fishing Clubs",
|
||||||
|
"trustOrganizations": "Non-Profit Organizations",
|
||||||
|
|
||||||
|
"featuresHeading": "Everything your organization needs",
|
||||||
|
"featuresSubheading": "From member management to finance — all the tools you need in one modern, easy-to-use platform.",
|
||||||
|
"featuresLabel": "Core Modules",
|
||||||
|
|
||||||
|
"featureMembersTitle": "Member Management",
|
||||||
|
"featureMembersDesc": "Manage all your members with departments, dues tracking, membership cards, applications, and detailed statistics.",
|
||||||
|
"featureCoursesTitle": "Course Management",
|
||||||
|
"featureCoursesDesc": "Organize courses with scheduling, instructor assignment, attendance tracking, categories, and locations.",
|
||||||
|
"featureBookingsTitle": "Room Bookings",
|
||||||
|
"featureBookingsDesc": "Book rooms and resources with a visual calendar, manage guests, and track availability at a glance.",
|
||||||
|
"featureEventsTitle": "Event Management",
|
||||||
|
"featureEventsDesc": "Plan and manage events with registrations, holiday passes, and participant tracking.",
|
||||||
|
"featureFinanceTitle": "Finance & Billing",
|
||||||
|
"featureFinanceDesc": "Handle invoices, payments, and SEPA direct debit collections — keep your finances organized effortlessly.",
|
||||||
|
"featureNewsletterTitle": "Newsletter",
|
||||||
|
"featureNewsletterDesc": "Create and send professional newsletters with templates. Keep your members informed and engaged.",
|
||||||
|
|
||||||
|
"showcaseHeading": "A powerful dashboard at your fingertips",
|
||||||
|
"showcaseDescription": "Get a complete overview of your organization with our intuitive dashboard. Access everything you need — members, courses, events, and finances — from one central hub.",
|
||||||
|
|
||||||
|
"additionalFeaturesHeading": "And there's more",
|
||||||
|
"additionalFeaturesSubheading": "Additional tools to streamline every aspect of your organization's daily work.",
|
||||||
|
"additionalFeaturesLabel": "More Features",
|
||||||
|
|
||||||
|
"featureDocumentsTitle": "Document Management",
|
||||||
|
"featureDocumentsDesc": "Generate documents from templates, manage files, and keep all your important documents organized.",
|
||||||
|
"featureSiteBuilderTitle": "Website Builder",
|
||||||
|
"featureSiteBuilderDesc": "Create and manage your organization's website without any programming knowledge. Update content with ease.",
|
||||||
|
"featureModulesTitle": "Custom Modules",
|
||||||
|
"featureModulesDesc": "Extend the platform with custom modules tailored to your specific needs. Import data and configure settings.",
|
||||||
|
|
||||||
|
"whyChooseHeading": "Why organizations choose MyEasyCMS",
|
||||||
|
"whyChooseDescription": "Built with over 20 years of experience serving associations, clubs, and non-profit organizations across Germany.",
|
||||||
|
"whyResponsiveTitle": "Mobile-Friendly",
|
||||||
|
"whyResponsiveDesc": "Access your data from any device. Our responsive design works perfectly on desktop, tablet, and smartphone.",
|
||||||
|
"whySecureTitle": "Secure & Reliable",
|
||||||
|
"whySecureDesc": "Your data is protected with enterprise-grade security. Regular backups ensure nothing is ever lost.",
|
||||||
|
"whySupportTitle": "Personal Support",
|
||||||
|
"whySupportDesc": "Get direct, personal support from our team. We speak your language and understand your needs.",
|
||||||
|
"whyGdprTitle": "GDPR Compliant",
|
||||||
|
"whyGdprDesc": "Fully compliant with European data protection regulations. Your members' data is handled with care.",
|
||||||
|
|
||||||
|
"howItWorksHeading": "Get started in three easy steps",
|
||||||
|
"howItWorksSubheading": "Setting up your organization on MyEasyCMS takes just minutes.",
|
||||||
|
"howStep1Title": "Create your account",
|
||||||
|
"howStep1Desc": "Sign up for free and set up your organization profile. No credit card required to get started.",
|
||||||
|
"howStep2Title": "Configure your modules",
|
||||||
|
"howStep2Desc": "Activate the modules you need — members, courses, events, finance — and customize them to fit your workflow.",
|
||||||
|
"howStep3Title": "Invite your team",
|
||||||
|
"howStep3Desc": "Add team members with different roles and permissions. Start managing your organization collaboratively.",
|
||||||
|
|
||||||
|
"pricingPillLabel": "Start for free",
|
||||||
|
"pricingPillText": "No credit card required.",
|
||||||
|
"pricingHeading": "Fair pricing for all types of organizations",
|
||||||
|
"pricingSubheading": "Get started on our free plan and upgrade when you are ready.",
|
||||||
|
|
||||||
|
"ctaHeading": "Ready to simplify your organization's management?",
|
||||||
|
"ctaDescription": "Join hundreds of associations, clubs, and organizations who already use MyEasyCMS to streamline their work.",
|
||||||
|
"ctaButtonPrimary": "Get Started for Free",
|
||||||
|
"ctaButtonSecondary": "Contact Us",
|
||||||
|
"ctaNote": "No credit card required. Free plan available."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,6 +496,73 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
cms_posts: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
author_id: string | null
|
||||||
|
content: string | null
|
||||||
|
cover_image: string | null
|
||||||
|
created_at: string
|
||||||
|
excerpt: string | null
|
||||||
|
id: string
|
||||||
|
published_at: string | null
|
||||||
|
slug: string
|
||||||
|
status: string
|
||||||
|
title: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
author_id?: string | null
|
||||||
|
content?: string | null
|
||||||
|
cover_image?: string | null
|
||||||
|
created_at?: string
|
||||||
|
excerpt?: string | null
|
||||||
|
id?: string
|
||||||
|
published_at?: string | null
|
||||||
|
slug: string
|
||||||
|
status?: string
|
||||||
|
title: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
author_id?: string | null
|
||||||
|
content?: string | null
|
||||||
|
cover_image?: string | null
|
||||||
|
created_at?: string
|
||||||
|
excerpt?: string | null
|
||||||
|
id?: string
|
||||||
|
published_at?: string | null
|
||||||
|
slug?: string
|
||||||
|
status?: string
|
||||||
|
title?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "cms_posts_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "cms_posts_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "cms_posts_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
config: {
|
config: {
|
||||||
Row: {
|
Row: {
|
||||||
billing_provider: Database["public"]["Enums"]["billing_provider"]
|
billing_provider: Database["public"]["Enums"]["billing_provider"]
|
||||||
@@ -963,6 +1030,8 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
interval: string
|
interval: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
is_exit: boolean
|
||||||
|
is_youth: boolean
|
||||||
name: string
|
name: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
}
|
}
|
||||||
@@ -974,6 +1043,8 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
interval?: string
|
interval?: string
|
||||||
is_default?: boolean
|
is_default?: boolean
|
||||||
|
is_exit?: boolean
|
||||||
|
is_youth?: boolean
|
||||||
name: string
|
name: string
|
||||||
sort_order?: number
|
sort_order?: number
|
||||||
}
|
}
|
||||||
@@ -985,6 +1056,8 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
interval?: string
|
interval?: string
|
||||||
is_default?: boolean
|
is_default?: boolean
|
||||||
|
is_exit?: boolean
|
||||||
|
is_youth?: boolean
|
||||||
name?: string
|
name?: string
|
||||||
sort_order?: number
|
sort_order?: number
|
||||||
}
|
}
|
||||||
@@ -1671,126 +1744,499 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
member_department_assignments: {
|
||||||
|
Row: {
|
||||||
|
department_id: string
|
||||||
|
member_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
department_id: string
|
||||||
|
member_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
department_id?: string
|
||||||
|
member_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_department_assignments_department_id_fkey"
|
||||||
|
columns: ["department_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "member_departments"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_department_assignments_member_id_fkey"
|
||||||
|
columns: ["member_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "members"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
member_departments: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
created_at: string
|
||||||
|
description: string | null
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_departments_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_departments_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_departments_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
member_honors: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
created_at: string
|
||||||
|
description: string | null
|
||||||
|
honor_date: string | null
|
||||||
|
honor_name: string
|
||||||
|
id: string
|
||||||
|
member_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
honor_date?: string | null
|
||||||
|
honor_name: string
|
||||||
|
id?: string
|
||||||
|
member_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
honor_date?: string | null
|
||||||
|
honor_name?: string
|
||||||
|
id?: string
|
||||||
|
member_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_honors_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_honors_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_honors_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_honors_member_id_fkey"
|
||||||
|
columns: ["member_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "members"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
member_portal_invitations: {
|
||||||
|
Row: {
|
||||||
|
accepted_at: string | null
|
||||||
|
account_id: string
|
||||||
|
created_at: string
|
||||||
|
email: string
|
||||||
|
expires_at: string
|
||||||
|
id: string
|
||||||
|
invite_token: string
|
||||||
|
invited_by: string | null
|
||||||
|
member_id: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
accepted_at?: string | null
|
||||||
|
account_id: string
|
||||||
|
created_at?: string
|
||||||
|
email: string
|
||||||
|
expires_at?: string
|
||||||
|
id?: string
|
||||||
|
invite_token?: string
|
||||||
|
invited_by?: string | null
|
||||||
|
member_id: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
accepted_at?: string | null
|
||||||
|
account_id?: string
|
||||||
|
created_at?: string
|
||||||
|
email?: string
|
||||||
|
expires_at?: string
|
||||||
|
id?: string
|
||||||
|
invite_token?: string
|
||||||
|
invited_by?: string | null
|
||||||
|
member_id?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_portal_invitations_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_portal_invitations_member_id_fkey"
|
||||||
|
columns: ["member_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "members"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
member_roles: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
created_at: string
|
||||||
|
from_date: string | null
|
||||||
|
id: string
|
||||||
|
is_active: boolean
|
||||||
|
member_id: string
|
||||||
|
role_name: string
|
||||||
|
until_date: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
created_at?: string
|
||||||
|
from_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
member_id: string
|
||||||
|
role_name: string
|
||||||
|
until_date?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
created_at?: string
|
||||||
|
from_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
member_id?: string
|
||||||
|
role_name?: string
|
||||||
|
until_date?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_roles_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_roles_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_roles_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "member_roles_member_id_fkey"
|
||||||
|
columns: ["member_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "members"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
members: {
|
members: {
|
||||||
Row: {
|
Row: {
|
||||||
account_holder: string | null
|
account_holder: string | null
|
||||||
account_id: string
|
account_id: string
|
||||||
|
additional_fees: number | null
|
||||||
|
address_invalid: boolean
|
||||||
bic: string | null
|
bic: string | null
|
||||||
|
birth_country: string | null
|
||||||
|
birthplace: string | null
|
||||||
city: string | null
|
city: string | null
|
||||||
country: string | null
|
country: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
created_by: string | null
|
created_by: string | null
|
||||||
custom_data: Json
|
custom_data: Json
|
||||||
|
data_reconciliation_needed: boolean
|
||||||
date_of_birth: string | null
|
date_of_birth: string | null
|
||||||
dues_category_id: string | null
|
dues_category_id: string | null
|
||||||
|
dues_paid: boolean
|
||||||
|
dues_year: number | null
|
||||||
email: string | null
|
email: string | null
|
||||||
|
email_confirmed: boolean
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
exemption_amount: number | null
|
||||||
|
exemption_reason: string | null
|
||||||
|
exemption_type: string | null
|
||||||
exit_date: string | null
|
exit_date: string | null
|
||||||
exit_reason: string | null
|
exit_reason: string | null
|
||||||
|
fax: string | null
|
||||||
first_name: string
|
first_name: string
|
||||||
|
gdpr_birthday_info: boolean
|
||||||
gdpr_consent: boolean
|
gdpr_consent: boolean
|
||||||
gdpr_consent_date: string | null
|
gdpr_consent_date: string | null
|
||||||
gdpr_data_source: string | null
|
gdpr_data_source: string | null
|
||||||
|
gdpr_internet: boolean
|
||||||
|
gdpr_newsletter: boolean
|
||||||
|
gdpr_print: boolean
|
||||||
gender: string | null
|
gender: string | null
|
||||||
|
guardian_email: string | null
|
||||||
|
guardian_name: string | null
|
||||||
|
guardian_phone: string | null
|
||||||
house_number: string | null
|
house_number: string | null
|
||||||
iban: string | null
|
iban: string | null
|
||||||
id: string
|
id: string
|
||||||
|
is_archived: boolean
|
||||||
|
is_founding_member: boolean
|
||||||
|
is_honorary: boolean
|
||||||
|
is_probationary: boolean
|
||||||
|
is_retiree: boolean
|
||||||
|
is_transferred: boolean
|
||||||
|
is_youth: boolean
|
||||||
last_name: string
|
last_name: string
|
||||||
member_number: string | null
|
member_number: string | null
|
||||||
mobile: string | null
|
mobile: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
online_access_blocked: boolean
|
||||||
|
online_access_key: string | null
|
||||||
phone: string | null
|
phone: string | null
|
||||||
|
phone2: string | null
|
||||||
postal_code: string | null
|
postal_code: string | null
|
||||||
|
salutation: string | null
|
||||||
|
sepa_bank_name: string | null
|
||||||
sepa_mandate_date: string | null
|
sepa_mandate_date: string | null
|
||||||
sepa_mandate_id: string | null
|
sepa_mandate_id: string | null
|
||||||
|
sepa_mandate_reference: string | null
|
||||||
|
sepa_mandate_sequence: string | null
|
||||||
sepa_mandate_status:
|
sepa_mandate_status:
|
||||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
| null
|
| null
|
||||||
status: Database["public"]["Enums"]["membership_status"]
|
status: Database["public"]["Enums"]["membership_status"]
|
||||||
street: string | null
|
street: string | null
|
||||||
|
street2: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
updated_by: string | null
|
updated_by: string | null
|
||||||
|
user_id: string | null
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
account_holder?: string | null
|
account_holder?: string | null
|
||||||
account_id: string
|
account_id: string
|
||||||
|
additional_fees?: number | null
|
||||||
|
address_invalid?: boolean
|
||||||
bic?: string | null
|
bic?: string | null
|
||||||
|
birth_country?: string | null
|
||||||
|
birthplace?: string | null
|
||||||
city?: string | null
|
city?: string | null
|
||||||
country?: string | null
|
country?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
created_by?: string | null
|
created_by?: string | null
|
||||||
custom_data?: Json
|
custom_data?: Json
|
||||||
|
data_reconciliation_needed?: boolean
|
||||||
date_of_birth?: string | null
|
date_of_birth?: string | null
|
||||||
dues_category_id?: string | null
|
dues_category_id?: string | null
|
||||||
|
dues_paid?: boolean
|
||||||
|
dues_year?: number | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
email_confirmed?: boolean
|
||||||
entry_date?: string
|
entry_date?: string
|
||||||
|
exemption_amount?: number | null
|
||||||
|
exemption_reason?: string | null
|
||||||
|
exemption_type?: string | null
|
||||||
exit_date?: string | null
|
exit_date?: string | null
|
||||||
exit_reason?: string | null
|
exit_reason?: string | null
|
||||||
|
fax?: string | null
|
||||||
first_name: string
|
first_name: string
|
||||||
|
gdpr_birthday_info?: boolean
|
||||||
gdpr_consent?: boolean
|
gdpr_consent?: boolean
|
||||||
gdpr_consent_date?: string | null
|
gdpr_consent_date?: string | null
|
||||||
gdpr_data_source?: string | null
|
gdpr_data_source?: string | null
|
||||||
|
gdpr_internet?: boolean
|
||||||
|
gdpr_newsletter?: boolean
|
||||||
|
gdpr_print?: boolean
|
||||||
gender?: string | null
|
gender?: string | null
|
||||||
|
guardian_email?: string | null
|
||||||
|
guardian_name?: string | null
|
||||||
|
guardian_phone?: string | null
|
||||||
house_number?: string | null
|
house_number?: string | null
|
||||||
iban?: string | null
|
iban?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
is_archived?: boolean
|
||||||
|
is_founding_member?: boolean
|
||||||
|
is_honorary?: boolean
|
||||||
|
is_probationary?: boolean
|
||||||
|
is_retiree?: boolean
|
||||||
|
is_transferred?: boolean
|
||||||
|
is_youth?: boolean
|
||||||
last_name: string
|
last_name: string
|
||||||
member_number?: string | null
|
member_number?: string | null
|
||||||
mobile?: string | null
|
mobile?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
|
online_access_blocked?: boolean
|
||||||
|
online_access_key?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
|
phone2?: string | null
|
||||||
postal_code?: string | null
|
postal_code?: string | null
|
||||||
|
salutation?: string | null
|
||||||
|
sepa_bank_name?: string | null
|
||||||
sepa_mandate_date?: string | null
|
sepa_mandate_date?: string | null
|
||||||
sepa_mandate_id?: string | null
|
sepa_mandate_id?: string | null
|
||||||
|
sepa_mandate_reference?: string | null
|
||||||
|
sepa_mandate_sequence?: string | null
|
||||||
sepa_mandate_status?:
|
sepa_mandate_status?:
|
||||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
| null
|
| null
|
||||||
status?: Database["public"]["Enums"]["membership_status"]
|
status?: Database["public"]["Enums"]["membership_status"]
|
||||||
street?: string | null
|
street?: string | null
|
||||||
|
street2?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
|
user_id?: string | null
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
account_holder?: string | null
|
account_holder?: string | null
|
||||||
account_id?: string
|
account_id?: string
|
||||||
|
additional_fees?: number | null
|
||||||
|
address_invalid?: boolean
|
||||||
bic?: string | null
|
bic?: string | null
|
||||||
|
birth_country?: string | null
|
||||||
|
birthplace?: string | null
|
||||||
city?: string | null
|
city?: string | null
|
||||||
country?: string | null
|
country?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
created_by?: string | null
|
created_by?: string | null
|
||||||
custom_data?: Json
|
custom_data?: Json
|
||||||
|
data_reconciliation_needed?: boolean
|
||||||
date_of_birth?: string | null
|
date_of_birth?: string | null
|
||||||
dues_category_id?: string | null
|
dues_category_id?: string | null
|
||||||
|
dues_paid?: boolean
|
||||||
|
dues_year?: number | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
email_confirmed?: boolean
|
||||||
entry_date?: string
|
entry_date?: string
|
||||||
|
exemption_amount?: number | null
|
||||||
|
exemption_reason?: string | null
|
||||||
|
exemption_type?: string | null
|
||||||
exit_date?: string | null
|
exit_date?: string | null
|
||||||
exit_reason?: string | null
|
exit_reason?: string | null
|
||||||
|
fax?: string | null
|
||||||
first_name?: string
|
first_name?: string
|
||||||
|
gdpr_birthday_info?: boolean
|
||||||
gdpr_consent?: boolean
|
gdpr_consent?: boolean
|
||||||
gdpr_consent_date?: string | null
|
gdpr_consent_date?: string | null
|
||||||
gdpr_data_source?: string | null
|
gdpr_data_source?: string | null
|
||||||
|
gdpr_internet?: boolean
|
||||||
|
gdpr_newsletter?: boolean
|
||||||
|
gdpr_print?: boolean
|
||||||
gender?: string | null
|
gender?: string | null
|
||||||
|
guardian_email?: string | null
|
||||||
|
guardian_name?: string | null
|
||||||
|
guardian_phone?: string | null
|
||||||
house_number?: string | null
|
house_number?: string | null
|
||||||
iban?: string | null
|
iban?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
is_archived?: boolean
|
||||||
|
is_founding_member?: boolean
|
||||||
|
is_honorary?: boolean
|
||||||
|
is_probationary?: boolean
|
||||||
|
is_retiree?: boolean
|
||||||
|
is_transferred?: boolean
|
||||||
|
is_youth?: boolean
|
||||||
last_name?: string
|
last_name?: string
|
||||||
member_number?: string | null
|
member_number?: string | null
|
||||||
mobile?: string | null
|
mobile?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
|
online_access_blocked?: boolean
|
||||||
|
online_access_key?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
|
phone2?: string | null
|
||||||
postal_code?: string | null
|
postal_code?: string | null
|
||||||
|
salutation?: string | null
|
||||||
|
sepa_bank_name?: string | null
|
||||||
sepa_mandate_date?: string | null
|
sepa_mandate_date?: string | null
|
||||||
sepa_mandate_id?: string | null
|
sepa_mandate_id?: string | null
|
||||||
|
sepa_mandate_reference?: string | null
|
||||||
|
sepa_mandate_sequence?: string | null
|
||||||
sepa_mandate_status?:
|
sepa_mandate_status?:
|
||||||
| Database["public"]["Enums"]["sepa_mandate_status"]
|
| Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
| null
|
| null
|
||||||
status?: Database["public"]["Enums"]["membership_status"]
|
status?: Database["public"]["Enums"]["membership_status"]
|
||||||
street?: string | null
|
street?: string | null
|
||||||
|
street2?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
|
user_id?: string | null
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
{
|
{
|
||||||
@@ -2412,6 +2858,64 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
newsletter_subscriptions: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
confirmation_token: string | null
|
||||||
|
confirmed_at: string | null
|
||||||
|
email: string
|
||||||
|
id: string
|
||||||
|
is_active: boolean
|
||||||
|
name: string | null
|
||||||
|
subscribed_at: string
|
||||||
|
unsubscribed_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
confirmation_token?: string | null
|
||||||
|
confirmed_at?: string | null
|
||||||
|
email: string
|
||||||
|
id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
name?: string | null
|
||||||
|
subscribed_at?: string
|
||||||
|
unsubscribed_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
confirmation_token?: string | null
|
||||||
|
confirmed_at?: string | null
|
||||||
|
email?: string
|
||||||
|
id?: string
|
||||||
|
is_active?: boolean
|
||||||
|
name?: string | null
|
||||||
|
subscribed_at?: string
|
||||||
|
unsubscribed_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "newsletter_subscriptions_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
newsletter_templates: {
|
newsletter_templates: {
|
||||||
Row: {
|
Row: {
|
||||||
account_id: string
|
account_id: string
|
||||||
@@ -3015,6 +3519,259 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
sepa_mandates: {
|
||||||
|
Row: {
|
||||||
|
account_holder: string
|
||||||
|
account_id: string
|
||||||
|
bic: string | null
|
||||||
|
created_at: string
|
||||||
|
has_error: boolean
|
||||||
|
iban: string
|
||||||
|
id: string
|
||||||
|
is_primary: boolean
|
||||||
|
last_used_at: string | null
|
||||||
|
mandate_date: string
|
||||||
|
mandate_reference: string
|
||||||
|
member_id: string
|
||||||
|
notes: string | null
|
||||||
|
sequence: string
|
||||||
|
status: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_holder: string
|
||||||
|
account_id: string
|
||||||
|
bic?: string | null
|
||||||
|
created_at?: string
|
||||||
|
has_error?: boolean
|
||||||
|
iban: string
|
||||||
|
id?: string
|
||||||
|
is_primary?: boolean
|
||||||
|
last_used_at?: string | null
|
||||||
|
mandate_date: string
|
||||||
|
mandate_reference: string
|
||||||
|
member_id: string
|
||||||
|
notes?: string | null
|
||||||
|
sequence?: string
|
||||||
|
status?: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_holder?: string
|
||||||
|
account_id?: string
|
||||||
|
bic?: string | null
|
||||||
|
created_at?: string
|
||||||
|
has_error?: boolean
|
||||||
|
iban?: string
|
||||||
|
id?: string
|
||||||
|
is_primary?: boolean
|
||||||
|
last_used_at?: string | null
|
||||||
|
mandate_date?: string
|
||||||
|
mandate_reference?: string
|
||||||
|
member_id?: string
|
||||||
|
notes?: string | null
|
||||||
|
sequence?: string
|
||||||
|
status?: Database["public"]["Enums"]["sepa_mandate_status"]
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "sepa_mandates_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "sepa_mandates_member_id_fkey"
|
||||||
|
columns: ["member_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "members"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
site_pages: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
id: string
|
||||||
|
is_homepage: boolean
|
||||||
|
is_members_only: boolean
|
||||||
|
is_published: boolean
|
||||||
|
meta_description: string | null
|
||||||
|
meta_image: string | null
|
||||||
|
published_at: string | null
|
||||||
|
puck_data: Json
|
||||||
|
slug: string
|
||||||
|
sort_order: number
|
||||||
|
title: string
|
||||||
|
updated_at: string
|
||||||
|
updated_by: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
id?: string
|
||||||
|
is_homepage?: boolean
|
||||||
|
is_members_only?: boolean
|
||||||
|
is_published?: boolean
|
||||||
|
meta_description?: string | null
|
||||||
|
meta_image?: string | null
|
||||||
|
published_at?: string | null
|
||||||
|
puck_data?: Json
|
||||||
|
slug: string
|
||||||
|
sort_order?: number
|
||||||
|
title: string
|
||||||
|
updated_at?: string
|
||||||
|
updated_by?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
id?: string
|
||||||
|
is_homepage?: boolean
|
||||||
|
is_members_only?: boolean
|
||||||
|
is_published?: boolean
|
||||||
|
meta_description?: string | null
|
||||||
|
meta_image?: string | null
|
||||||
|
published_at?: string | null
|
||||||
|
puck_data?: Json
|
||||||
|
slug?: string
|
||||||
|
sort_order?: number
|
||||||
|
title?: string
|
||||||
|
updated_at?: string
|
||||||
|
updated_by?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_pages_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_pages_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_pages_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
site_settings: {
|
||||||
|
Row: {
|
||||||
|
account_id: string
|
||||||
|
contact_address: string | null
|
||||||
|
contact_email: string | null
|
||||||
|
contact_phone: string | null
|
||||||
|
created_at: string
|
||||||
|
custom_css: string | null
|
||||||
|
custom_domain: string | null
|
||||||
|
datenschutz: string | null
|
||||||
|
font_family: string | null
|
||||||
|
footer_text: string | null
|
||||||
|
impressum: string | null
|
||||||
|
is_public: boolean
|
||||||
|
navigation: Json
|
||||||
|
primary_color: string | null
|
||||||
|
secondary_color: string | null
|
||||||
|
site_logo: string | null
|
||||||
|
site_name: string | null
|
||||||
|
social_links: Json | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
account_id: string
|
||||||
|
contact_address?: string | null
|
||||||
|
contact_email?: string | null
|
||||||
|
contact_phone?: string | null
|
||||||
|
created_at?: string
|
||||||
|
custom_css?: string | null
|
||||||
|
custom_domain?: string | null
|
||||||
|
datenschutz?: string | null
|
||||||
|
font_family?: string | null
|
||||||
|
footer_text?: string | null
|
||||||
|
impressum?: string | null
|
||||||
|
is_public?: boolean
|
||||||
|
navigation?: Json
|
||||||
|
primary_color?: string | null
|
||||||
|
secondary_color?: string | null
|
||||||
|
site_logo?: string | null
|
||||||
|
site_name?: string | null
|
||||||
|
social_links?: Json | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
account_id?: string
|
||||||
|
contact_address?: string | null
|
||||||
|
contact_email?: string | null
|
||||||
|
contact_phone?: string | null
|
||||||
|
created_at?: string
|
||||||
|
custom_css?: string | null
|
||||||
|
custom_domain?: string | null
|
||||||
|
datenschutz?: string | null
|
||||||
|
font_family?: string | null
|
||||||
|
footer_text?: string | null
|
||||||
|
impressum?: string | null
|
||||||
|
is_public?: boolean
|
||||||
|
navigation?: Json
|
||||||
|
primary_color?: string | null
|
||||||
|
secondary_color?: string | null
|
||||||
|
site_logo?: string | null
|
||||||
|
site_name?: string | null
|
||||||
|
social_links?: Json | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_settings_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_settings_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "user_account_workspace"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "site_settings_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "user_accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
subscription_items: {
|
subscription_items: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -3194,6 +3951,22 @@ export type Database = {
|
|||||||
Args: { target_team_account_id: string; target_user_id: string }
|
Args: { target_team_account_id: string; target_user_id: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
}
|
}
|
||||||
|
check_duplicate_member: {
|
||||||
|
Args: {
|
||||||
|
p_account_id: string
|
||||||
|
p_date_of_birth?: string
|
||||||
|
p_first_name: string
|
||||||
|
p_last_name: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
date_of_birth: string
|
||||||
|
first_name: string
|
||||||
|
id: string
|
||||||
|
last_name: string
|
||||||
|
member_number: string
|
||||||
|
status: Database["public"]["Enums"]["membership_status"]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
create_invitation: {
|
create_invitation: {
|
||||||
Args: { account_id: string; email: string; role: string }
|
Args: { account_id: string; email: string; role: string }
|
||||||
Returns: {
|
Returns: {
|
||||||
@@ -3327,6 +4100,10 @@ export type Database = {
|
|||||||
Args: { account_id: string; user_id: string }
|
Args: { account_id: string; user_id: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
}
|
}
|
||||||
|
link_member_to_user: {
|
||||||
|
Args: { p_invite_token: string; p_user_id: string }
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
module_query: {
|
module_query: {
|
||||||
Args: {
|
Args: {
|
||||||
p_filters?: Json
|
p_filters?: Json
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@kit/site-builder": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@next/bundle-analyzer": "catalog:",
|
"@next/bundle-analyzer": "catalog:",
|
||||||
"@tailwindcss/postcss": "catalog:",
|
"@tailwindcss/postcss": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Member Management Parity Migration
|
||||||
|
* Adds missing columns, departments, roles, honors,
|
||||||
|
* SEPA mandates table, dues extensions, duplicate detection
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A1. Add missing columns to members table
|
||||||
|
-- =====================================================
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS salutation text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS street2 text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS phone2 text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS fax text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birthplace text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS birth_country text DEFAULT 'DE';
|
||||||
|
|
||||||
|
-- Membership lifecycle flags
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_honorary boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_founding_member boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_retiree boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_probationary boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_transferred boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Youth/Guardian
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_name text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_phone text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS guardian_email text;
|
||||||
|
|
||||||
|
-- Dues tracking
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_year integer;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS dues_paid boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS additional_fees numeric(10,2) DEFAULT 0;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_type text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_reason text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS exemption_amount numeric(10,2);
|
||||||
|
|
||||||
|
-- SEPA extras
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_reference text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_mandate_sequence text DEFAULT 'RCUR';
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS sepa_bank_name text;
|
||||||
|
|
||||||
|
-- GDPR granular
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_newsletter boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_internet boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_print boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS gdpr_birthday_info boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Online portal
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_key text;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS online_access_blocked boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS email_confirmed boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Address quality
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS address_invalid boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS data_reconciliation_needed boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A2. member_departments + assignments
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_departments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_departments_account ON public.member_departments(account_id);
|
||||||
|
ALTER TABLE public.member_departments ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_departments FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_departments TO authenticated;
|
||||||
|
GRANT ALL ON public.member_departments TO service_role;
|
||||||
|
CREATE POLICY member_departments_select ON public.member_departments FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
CREATE POLICY member_departments_mutate ON public.member_departments FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_department_assignments (
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
department_id uuid NOT NULL REFERENCES public.member_departments(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (member_id, department_id)
|
||||||
|
);
|
||||||
|
ALTER TABLE public.member_department_assignments ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_department_assignments FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, DELETE ON public.member_department_assignments TO authenticated;
|
||||||
|
GRANT ALL ON public.member_department_assignments TO service_role;
|
||||||
|
CREATE POLICY mda_select ON public.member_department_assignments FOR SELECT TO authenticated
|
||||||
|
USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_role_on_account(m.account_id)));
|
||||||
|
CREATE POLICY mda_mutate ON public.member_department_assignments FOR ALL TO authenticated
|
||||||
|
USING (EXISTS (SELECT 1 FROM public.members m WHERE m.id = member_department_assignments.member_id AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions)));
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A3. member_roles (board positions / Funktionen)
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_roles (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
role_name text NOT NULL,
|
||||||
|
from_date date,
|
||||||
|
until_date date,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_roles_member ON public.member_roles(member_id);
|
||||||
|
ALTER TABLE public.member_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_roles FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_roles TO authenticated;
|
||||||
|
GRANT ALL ON public.member_roles TO service_role;
|
||||||
|
CREATE POLICY member_roles_select ON public.member_roles FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
CREATE POLICY member_roles_mutate ON public.member_roles FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A4. member_honors (Ehrungen)
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_honors (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
honor_name text NOT NULL,
|
||||||
|
honor_date date,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_honors_member ON public.member_honors(member_id);
|
||||||
|
ALTER TABLE public.member_honors ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_honors FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_honors TO authenticated;
|
||||||
|
GRANT ALL ON public.member_honors TO service_role;
|
||||||
|
CREATE POLICY member_honors_select ON public.member_honors FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
CREATE POLICY member_honors_mutate ON public.member_honors FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A5. sepa_mandates (proper sub-table)
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sepa_mandates (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
mandate_reference text NOT NULL,
|
||||||
|
iban text NOT NULL,
|
||||||
|
bic text,
|
||||||
|
account_holder text NOT NULL,
|
||||||
|
mandate_date date NOT NULL,
|
||||||
|
status public.sepa_mandate_status NOT NULL DEFAULT 'active',
|
||||||
|
sequence text NOT NULL DEFAULT 'RCUR' CHECK (sequence IN ('FRST','RCUR','FNAL','OOFF')),
|
||||||
|
is_primary boolean NOT NULL DEFAULT true,
|
||||||
|
has_error boolean NOT NULL DEFAULT false,
|
||||||
|
last_used_at timestamptz,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_member ON public.sepa_mandates(member_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_account ON public.sepa_mandates(account_id);
|
||||||
|
ALTER TABLE public.sepa_mandates ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.sepa_mandates FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.sepa_mandates TO authenticated;
|
||||||
|
GRANT ALL ON public.sepa_mandates TO service_role;
|
||||||
|
CREATE POLICY sepa_mandates_select ON public.sepa_mandates FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
CREATE POLICY sepa_mandates_mutate ON public.sepa_mandates FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_sepa_mandates_updated_at
|
||||||
|
BEFORE UPDATE ON public.sepa_mandates
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A6. Extend dues_categories
|
||||||
|
-- =====================================================
|
||||||
|
ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_youth boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE public.dues_categories ADD COLUMN IF NOT EXISTS is_exit boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- A7. Duplicate detection function
|
||||||
|
-- =====================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_duplicate_member(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_first_name text,
|
||||||
|
p_last_name text,
|
||||||
|
p_date_of_birth date DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE(id uuid, member_number text, first_name text, last_name text, date_of_birth date, status public.membership_status)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
SELECT m.id, m.member_number, m.first_name, m.last_name, m.date_of_birth, m.status
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = p_account_id
|
||||||
|
AND lower(m.first_name) = lower(p_first_name)
|
||||||
|
AND lower(m.last_name) = lower(p_last_name)
|
||||||
|
AND (p_date_of_birth IS NULL OR m.date_of_birth = p_date_of_birth);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.check_duplicate_member(uuid, text, text, date) TO authenticated, service_role;
|
||||||
176
apps/web/supabase/migrations/20260410000001_site_builder.sql
Normal file
176
apps/web/supabase/migrations/20260410000001_site_builder.sql
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
* Site Builder Migration
|
||||||
|
* Tables: site_pages, site_settings, cms_posts, newsletter_subscriptions
|
||||||
|
* Public read via anon + RLS
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 1. site_pages — Puck JSON per page
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.site_pages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
slug text NOT NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
puck_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
is_published boolean NOT NULL DEFAULT false,
|
||||||
|
is_homepage boolean NOT NULL DEFAULT false,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
meta_description text,
|
||||||
|
meta_image text,
|
||||||
|
created_by uuid REFERENCES auth.users(id),
|
||||||
|
updated_by uuid REFERENCES auth.users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
published_at timestamptz,
|
||||||
|
UNIQUE(account_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_site_pages_account ON public.site_pages(account_id);
|
||||||
|
CREATE INDEX idx_site_pages_published ON public.site_pages(account_id, is_published);
|
||||||
|
|
||||||
|
ALTER TABLE public.site_pages ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.site_pages FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.site_pages TO authenticated;
|
||||||
|
GRANT SELECT ON public.site_pages TO anon;
|
||||||
|
GRANT ALL ON public.site_pages TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY site_pages_public_read ON public.site_pages
|
||||||
|
FOR SELECT TO anon USING (is_published = true);
|
||||||
|
|
||||||
|
CREATE POLICY site_pages_auth_read ON public.site_pages
|
||||||
|
FOR SELECT TO authenticated USING (
|
||||||
|
is_published = true OR public.has_role_on_account(account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY site_pages_admin_write ON public.site_pages
|
||||||
|
FOR ALL TO authenticated USING (
|
||||||
|
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_site_pages_updated
|
||||||
|
BEFORE UPDATE ON public.site_pages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 2. site_settings — Per-club branding
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.site_settings (
|
||||||
|
account_id uuid PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
site_name text,
|
||||||
|
site_logo text,
|
||||||
|
primary_color text DEFAULT '#2563eb',
|
||||||
|
secondary_color text DEFAULT '#64748b',
|
||||||
|
font_family text DEFAULT 'Inter',
|
||||||
|
custom_css text,
|
||||||
|
navigation jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
footer_text text,
|
||||||
|
contact_email text,
|
||||||
|
contact_phone text,
|
||||||
|
contact_address text,
|
||||||
|
social_links jsonb DEFAULT '{}'::jsonb,
|
||||||
|
impressum text,
|
||||||
|
datenschutz text,
|
||||||
|
custom_domain text,
|
||||||
|
is_public boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.site_settings FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.site_settings TO authenticated;
|
||||||
|
GRANT SELECT ON public.site_settings TO anon;
|
||||||
|
GRANT ALL ON public.site_settings TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY site_settings_public_read ON public.site_settings
|
||||||
|
FOR SELECT TO anon USING (is_public = true);
|
||||||
|
|
||||||
|
CREATE POLICY site_settings_auth_read ON public.site_settings
|
||||||
|
FOR SELECT TO authenticated USING (
|
||||||
|
is_public = true OR public.has_role_on_account(account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY site_settings_admin_write ON public.site_settings
|
||||||
|
FOR ALL TO authenticated USING (
|
||||||
|
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_site_settings_updated
|
||||||
|
BEFORE UPDATE ON public.site_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 3. cms_posts — News/blog per club
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.cms_posts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
slug text NOT NULL,
|
||||||
|
content text,
|
||||||
|
excerpt text,
|
||||||
|
cover_image text,
|
||||||
|
author_id uuid REFERENCES auth.users(id),
|
||||||
|
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')),
|
||||||
|
published_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cms_posts_account ON public.cms_posts(account_id);
|
||||||
|
CREATE INDEX idx_cms_posts_published ON public.cms_posts(account_id, status);
|
||||||
|
|
||||||
|
ALTER TABLE public.cms_posts ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.cms_posts FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.cms_posts TO authenticated;
|
||||||
|
GRANT SELECT ON public.cms_posts TO anon;
|
||||||
|
GRANT ALL ON public.cms_posts TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY cms_posts_public_read ON public.cms_posts
|
||||||
|
FOR SELECT TO anon USING (status = 'published');
|
||||||
|
|
||||||
|
CREATE POLICY cms_posts_auth_read ON public.cms_posts
|
||||||
|
FOR SELECT TO authenticated USING (
|
||||||
|
status = 'published' OR public.has_role_on_account(account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY cms_posts_admin_write ON public.cms_posts
|
||||||
|
FOR ALL TO authenticated USING (
|
||||||
|
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_cms_posts_updated
|
||||||
|
BEFORE UPDATE ON public.cms_posts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 4. newsletter_subscriptions — Public signup
|
||||||
|
-- =====================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS public.newsletter_subscriptions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
email text NOT NULL,
|
||||||
|
name text,
|
||||||
|
subscribed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
unsubscribed_at timestamptz,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
confirmation_token text,
|
||||||
|
confirmed_at timestamptz,
|
||||||
|
UNIQUE(account_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_newsletter_subs_account ON public.newsletter_subscriptions(account_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.newsletter_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.newsletter_subscriptions FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.newsletter_subscriptions TO authenticated;
|
||||||
|
GRANT INSERT ON public.newsletter_subscriptions TO anon;
|
||||||
|
GRANT ALL ON public.newsletter_subscriptions TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY newsletter_sub_public_insert ON public.newsletter_subscriptions
|
||||||
|
FOR INSERT TO anon WITH CHECK (true);
|
||||||
|
|
||||||
|
CREATE POLICY newsletter_sub_admin ON public.newsletter_subscriptions
|
||||||
|
FOR ALL TO authenticated USING (public.has_role_on_account(account_id));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Fix: Grant anon USAGE on public schema for public page reads
|
||||||
|
GRANT USAGE ON SCHEMA public TO anon;
|
||||||
|
|
||||||
|
-- Ensure anon can read accounts table (needed to resolve club slugs)
|
||||||
|
GRANT SELECT ON public.accounts TO anon;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Member Portal Auth + Invitations
|
||||||
|
* Links members to auth.users, adds invitation system
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Add user_id to members (links to Supabase Auth)
|
||||||
|
ALTER TABLE public.members ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_user ON public.members(user_id);
|
||||||
|
|
||||||
|
-- Member portal invitations
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_portal_invitations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
email text NOT NULL,
|
||||||
|
invite_token text NOT NULL DEFAULT gen_random_uuid()::text,
|
||||||
|
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','expired','revoked')),
|
||||||
|
invited_by uuid REFERENCES auth.users(id),
|
||||||
|
accepted_at timestamptz,
|
||||||
|
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_portal_invitations_token ON public.member_portal_invitations(invite_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_portal_invitations_member ON public.member_portal_invitations(member_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_portal_invitations ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_portal_invitations FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.member_portal_invitations TO authenticated;
|
||||||
|
GRANT ALL ON public.member_portal_invitations TO service_role;
|
||||||
|
|
||||||
|
-- Admins can manage invitations for their account
|
||||||
|
CREATE POLICY portal_invitations_admin ON public.member_portal_invitations
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- Anon can read invitation by token (for the accept flow)
|
||||||
|
GRANT SELECT ON public.member_portal_invitations TO anon;
|
||||||
|
CREATE POLICY portal_invitations_anon_read ON public.member_portal_invitations
|
||||||
|
FOR SELECT TO anon
|
||||||
|
USING (status = 'pending' AND expires_at > now());
|
||||||
|
|
||||||
|
-- RLS: Members can read their own portal data
|
||||||
|
-- Allow authenticated users to read their own member record via user_id
|
||||||
|
CREATE POLICY members_portal_self_read ON public.members
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Allow members to update their own contact/gdpr fields
|
||||||
|
CREATE POLICY members_portal_self_update ON public.members
|
||||||
|
FOR UPDATE TO authenticated
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Add is_members_only flag to site_pages for member-only content
|
||||||
|
ALTER TABLE public.site_pages ADD COLUMN IF NOT EXISTS is_members_only boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Function: Link member to auth user after signup
|
||||||
|
CREATE OR REPLACE FUNCTION public.link_member_to_user(
|
||||||
|
p_invite_token text,
|
||||||
|
p_user_id uuid
|
||||||
|
) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_member_id uuid;
|
||||||
|
v_account_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Find and validate invitation
|
||||||
|
SELECT member_id, account_id INTO v_member_id, v_account_id
|
||||||
|
FROM public.member_portal_invitations
|
||||||
|
WHERE invite_token = p_invite_token
|
||||||
|
AND status = 'pending'
|
||||||
|
AND expires_at > now();
|
||||||
|
|
||||||
|
IF v_member_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Invalid or expired invitation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Link member to user
|
||||||
|
UPDATE public.members SET user_id = p_user_id WHERE id = v_member_id;
|
||||||
|
|
||||||
|
-- Mark invitation as accepted
|
||||||
|
UPDATE public.member_portal_invitations
|
||||||
|
SET status = 'accepted', accepted_at = now()
|
||||||
|
WHERE invite_token = p_invite_token;
|
||||||
|
|
||||||
|
RETURN v_member_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.link_member_to_user(text, uuid) TO authenticated, service_role;
|
||||||
147
docker-compose.yml
Normal file
147
docker-compose.yml
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# MyEasyCMS v2 — Docker Compose for Dokploy deployment
|
||||||
|
# Supabase (self-hosted) + Next.js app
|
||||||
|
|
||||||
|
services:
|
||||||
|
# =====================================================
|
||||||
|
# Supabase Stack
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
supabase-db:
|
||||||
|
image: supabase/postgres:15.8.1.060
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- supabase-db-data:/var/lib/postgresql/data
|
||||||
|
- ./apps/web/supabase/migrations:/docker-entrypoint-initdb.d/migrations
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-your-super-secret-password}
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
supabase-auth:
|
||||||
|
image: supabase/gotrue:v2.172.1
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
supabase-db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
GOTRUE_API_HOST: 0.0.0.0
|
||||||
|
GOTRUE_API_PORT: 9999
|
||||||
|
API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:8000}
|
||||||
|
GOTRUE_DB_DRIVER: postgres
|
||||||
|
GOTRUE_DB_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres?search_path=auth
|
||||||
|
GOTRUE_SITE_URL: ${SITE_URL:-https://myeasycms.de}
|
||||||
|
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
|
||||||
|
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
|
||||||
|
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||||
|
GOTRUE_JWT_AUD: authenticated
|
||||||
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||||
|
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||||
|
GOTRUE_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
|
||||||
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
|
||||||
|
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
|
||||||
|
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
||||||
|
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@myeasycms.de}
|
||||||
|
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
|
||||||
|
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
|
||||||
|
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
||||||
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
|
||||||
|
|
||||||
|
supabase-rest:
|
||||||
|
image: postgrest/postgrest:v12.2.8
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
supabase-db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres
|
||||||
|
PGRST_DB_SCHEMAS: public,storage,graphql_public
|
||||||
|
PGRST_DB_ANON_ROLE: anon
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
|
||||||
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||||
|
|
||||||
|
supabase-storage:
|
||||||
|
image: supabase/storage-api:v1.22.7
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
supabase-db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- supabase-storage-data:/var/lib/storage
|
||||||
|
environment:
|
||||||
|
ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
|
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
POSTGREST_URL: http://supabase-rest:3000
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters}
|
||||||
|
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres
|
||||||
|
FILE_SIZE_LIMIT: 52428800
|
||||||
|
STORAGE_BACKEND: file
|
||||||
|
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||||
|
TENANT_ID: stub
|
||||||
|
REGION: local
|
||||||
|
GLOBAL_S3_BUCKET: stub
|
||||||
|
|
||||||
|
supabase-kong:
|
||||||
|
image: kong:2.8.1
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- supabase-auth
|
||||||
|
- supabase-rest
|
||||||
|
- supabase-storage
|
||||||
|
ports:
|
||||||
|
- "${KONG_HTTP_PORT:-8000}:8000"
|
||||||
|
- "${KONG_HTTPS_PORT:-8443}:8443"
|
||||||
|
environment:
|
||||||
|
KONG_DATABASE: "off"
|
||||||
|
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
|
||||||
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
|
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./docker/kong.yml:/var/lib/kong/kong.yml:ro
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# Next.js App
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- supabase-kong
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://myeasycms.de}
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY: ${SUPABASE_ANON_KEY}
|
||||||
|
SUPABASE_SECRET_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
|
||||||
|
NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS
|
||||||
|
NEXT_PUBLIC_DEFAULT_LOCALE: de
|
||||||
|
NEXT_PUBLIC_ENABLE_THEME_TOGGLE: "true"
|
||||||
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: "true"
|
||||||
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: "true"
|
||||||
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: "false"
|
||||||
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: "false"
|
||||||
|
NEXT_PUBLIC_ENABLE_NOTIFICATIONS: "true"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
supabase-db-data:
|
||||||
|
supabase-storage-data:
|
||||||
58
docker/kong.yml
Normal file
58
docker/kong.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
_format_version: "1.1"
|
||||||
|
|
||||||
|
consumers:
|
||||||
|
- username: anon
|
||||||
|
keyauth_credentials:
|
||||||
|
- key: ${SUPABASE_ANON_KEY}
|
||||||
|
- username: service_role
|
||||||
|
keyauth_credentials:
|
||||||
|
- key: ${SUPABASE_SERVICE_KEY}
|
||||||
|
|
||||||
|
acls:
|
||||||
|
- consumer: anon
|
||||||
|
group: anon
|
||||||
|
- consumer: service_role
|
||||||
|
group: admin
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Auth
|
||||||
|
- name: auth-v1
|
||||||
|
url: http://supabase-auth:9999/
|
||||||
|
routes:
|
||||||
|
- name: auth-v1-routes
|
||||||
|
strip_path: true
|
||||||
|
paths:
|
||||||
|
- /auth/v1/
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
|
|
||||||
|
# REST (PostgREST)
|
||||||
|
- name: rest-v1
|
||||||
|
url: http://supabase-rest:3000/
|
||||||
|
routes:
|
||||||
|
- name: rest-v1-routes
|
||||||
|
strip_path: true
|
||||||
|
paths:
|
||||||
|
- /rest/v1/
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
|
- name: key-auth
|
||||||
|
config:
|
||||||
|
hide_credentials: false
|
||||||
|
- name: acl
|
||||||
|
config:
|
||||||
|
hide_groups_header: true
|
||||||
|
allow:
|
||||||
|
- anon
|
||||||
|
- admin
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
- name: storage-v1
|
||||||
|
url: http://supabase-storage:5000/
|
||||||
|
routes:
|
||||||
|
- name: storage-v1-routes
|
||||||
|
strip_path: true
|
||||||
|
paths:
|
||||||
|
- /storage/v1/
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
@@ -36,11 +36,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@manypkg/cli": "catalog:",
|
"@manypkg/cli": "catalog:",
|
||||||
|
"@measured/puck": "catalog:",
|
||||||
|
"@react-pdf/renderer": "catalog:",
|
||||||
"@tiptap/pm": "catalog:",
|
"@tiptap/pm": "catalog:",
|
||||||
"@tiptap/react": "catalog:",
|
"@tiptap/react": "catalog:",
|
||||||
"@tiptap/starter-kit": "catalog:",
|
"@tiptap/starter-kit": "catalog:",
|
||||||
"@turbo/gen": "catalog:",
|
"@turbo/gen": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/papaparse": "catalog:",
|
||||||
"cross-env": "catalog:",
|
"cross-env": "catalog:",
|
||||||
"exceljs": "catalog:",
|
"exceljs": "catalog:",
|
||||||
"iban": "catalog:",
|
"iban": "catalog:",
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts",
|
||||||
|
"./actions/*": "./src/server/actions/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { CreateBookingSchema } from '../schema/booking.schema';
|
||||||
|
import { createBooking } from '../server/actions/booking-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
rooms: Array<{ id: string; roomNumber: string; name?: string; pricePerNight: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateBookingForm({ accountId, account, rooms }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateBookingSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
roomId: '',
|
||||||
|
checkIn: '',
|
||||||
|
checkOut: '',
|
||||||
|
adults: 1,
|
||||||
|
children: 0,
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
totalPrice: 0,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createBooking, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Buchung erfolgreich erstellt');
|
||||||
|
router.push(`/home/${account}/bookings-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen der Buchung');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Zimmer & Zeitraum</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="roomId" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Zimmer *</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="">— Zimmer wählen —</option>
|
||||||
|
{rooms.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.roomNumber}{r.name ? ` – ${r.name}` : ''} ({r.pricePerNight} €/Nacht)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="checkIn" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Check-in *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="checkOut" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Check-out *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Gäste</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="adults" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Erwachsene *</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="children" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kinder</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Preis & Notizen</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="totalPrice" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Gesamtpreis (€)</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="status" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Status</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="pending">Ausstehend</option>
|
||||||
|
<option value="confirmed">Bestätigt</option>
|
||||||
|
<option value="checked_in">Eingecheckt</option>
|
||||||
|
<option value="checked_out">Ausgecheckt</option>
|
||||||
|
<option value="cancelled">Storniert</option>
|
||||||
|
<option value="no_show">Nicht erschienen</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormField control={form.control} name="notes" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Buchung erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export {};
|
export { CreateBookingForm } from './create-booking-form';
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
CreateBookingSchema,
|
||||||
|
CreateGuestSchema,
|
||||||
|
CreateRoomSchema,
|
||||||
|
} from '../../schema/booking.schema';
|
||||||
|
import { createBookingManagementApi } from '../api';
|
||||||
|
|
||||||
|
export const createBooking = authActionClient
|
||||||
|
.inputSchema(CreateBookingSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'booking.create' }, 'Creating booking...');
|
||||||
|
const result = await api.createBooking(input);
|
||||||
|
logger.info({ name: 'booking.create' }, 'Booking created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateBookingStatus = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
bookingId: z.string().uuid(),
|
||||||
|
status: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
|
||||||
|
const result = await api.updateBookingStatus(input.bookingId, input.status);
|
||||||
|
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRoom = authActionClient
|
||||||
|
.inputSchema(CreateRoomSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
|
||||||
|
const result = await api.createRoom(input);
|
||||||
|
logger.info({ name: 'booking.createRoom' }, 'Room created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createGuest = authActionClient
|
||||||
|
.inputSchema(CreateGuestSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
|
||||||
|
const result = await api.createGuest(input);
|
||||||
|
logger.info({ name: 'booking.createGuest' }, 'Guest created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
@@ -84,5 +84,15 @@ export function createBookingManagementApi(client: SupabaseClient<Database>) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createRoom(input: { accountId: string; roomNumber: string; name?: string; roomType?: string; capacity?: number; floor?: number; pricePerNight: number; description?: string }) {
|
||||||
|
const { data, error } = await client.from('rooms').insert({
|
||||||
|
account_id: input.accountId, room_number: input.roomNumber, name: input.name,
|
||||||
|
room_type: input.roomType ?? 'standard', capacity: input.capacity ?? 2,
|
||||||
|
floor: input.floor, price_per_night: input.pricePerNight, description: input.description,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts",
|
||||||
|
"./actions/*": "./src/server/actions/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { CreateCourseSchema } from '../schema/course.schema';
|
||||||
|
import { createCourse } from '../server/actions/course-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCourseForm({ accountId, account }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateCourseSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
courseNumber: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
fee: 0,
|
||||||
|
reducedFee: 0,
|
||||||
|
capacity: 20,
|
||||||
|
minParticipants: 5,
|
||||||
|
status: 'planned' as const,
|
||||||
|
registrationDeadline: '',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createCourse, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Kurs erfolgreich erstellt');
|
||||||
|
router.push(`/home/${account}/courses-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="courseNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kursnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="name" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kursname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormField control={form.control} name="description" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="startDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Startdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="endDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Kapazität</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="capacity" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={1} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="minParticipants" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Min. Teilnehmer</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="fee" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Gebühr (€)</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="reducedFee" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Ermäßigte Gebühr (€)</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Status</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="status" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kursstatus</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="planned">Geplant</option>
|
||||||
|
<option value="open">Offen</option>
|
||||||
|
<option value="running">Laufend</option>
|
||||||
|
<option value="completed">Abgeschlossen</option>
|
||||||
|
<option value="cancelled">Abgesagt</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<div className="sm:col-span-1">
|
||||||
|
<FormField control={form.control} name="notes" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Notizen</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Kurs erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export {};
|
export { CreateCourseForm } from './create-course-form';
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
CreateCourseSchema,
|
||||||
|
EnrollParticipantSchema,
|
||||||
|
CreateSessionSchema,
|
||||||
|
CreateCategorySchema,
|
||||||
|
CreateInstructorSchema,
|
||||||
|
CreateLocationSchema,
|
||||||
|
} from '../../schema/course.schema';
|
||||||
|
import { createCourseManagementApi } from '../api';
|
||||||
|
|
||||||
|
export const createCourse = authActionClient
|
||||||
|
.inputSchema(CreateCourseSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.create' }, 'Creating course...');
|
||||||
|
const result = await api.createCourse(input);
|
||||||
|
logger.info({ name: 'course.create' }, 'Course created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enrollParticipant = authActionClient
|
||||||
|
.inputSchema(EnrollParticipantSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.enrollParticipant' }, 'Enrolling participant...');
|
||||||
|
const result = await api.enrollParticipant(input);
|
||||||
|
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelEnrollment = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
participantId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.cancelEnrollment' }, 'Cancelling enrollment...');
|
||||||
|
const result = await api.cancelEnrollment(input.participantId);
|
||||||
|
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markAttendance = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
participantId: z.string().uuid(),
|
||||||
|
present: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
|
||||||
|
const result = await api.markAttendance(input.sessionId, input.participantId, input.present);
|
||||||
|
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCategory = authActionClient
|
||||||
|
.inputSchema(CreateCategorySchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.createCategory' }, 'Creating category...');
|
||||||
|
const result = await api.createCategory(input);
|
||||||
|
logger.info({ name: 'course.createCategory' }, 'Category created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createInstructor = authActionClient
|
||||||
|
.inputSchema(CreateInstructorSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
|
||||||
|
const result = await api.createInstructor(input);
|
||||||
|
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createLocation = authActionClient
|
||||||
|
.inputSchema(CreateLocationSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.createLocation' }, 'Creating location...');
|
||||||
|
const result = await api.createLocation(input);
|
||||||
|
logger.info({ name: 'course.createLocation' }, 'Location created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSession = authActionClient
|
||||||
|
.inputSchema(CreateSessionSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'course.createSession' }, 'Creating session...');
|
||||||
|
const result = await api.createSession(input);
|
||||||
|
logger.info({ name: 'course.createSession' }, 'Session created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
@@ -141,5 +141,34 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
|||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Create methods for CRUD ---
|
||||||
|
async createCategory(input: { accountId: string; name: string; description?: string; parentId?: string }) {
|
||||||
|
const { data, error } = await client.from('course_categories').insert({
|
||||||
|
account_id: input.accountId, name: input.name, description: input.description,
|
||||||
|
parent_id: input.parentId,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createInstructor(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; qualifications?: string; hourlyRate?: number }) {
|
||||||
|
const { data, error } = await client.from('course_instructors').insert({
|
||||||
|
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
|
||||||
|
email: input.email, phone: input.phone, qualifications: input.qualifications,
|
||||||
|
hourly_rate: input.hourlyRate,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLocation(input: { accountId: string; name: string; address?: string; room?: string; capacity?: number }) {
|
||||||
|
const { data, error } = await client.from('course_locations').insert({
|
||||||
|
account_id: input.accountId, name: input.name, address: input.address,
|
||||||
|
room: input.room, capacity: input.capacity,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts",
|
||||||
|
"./actions/*": "./src/server/actions/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { CreateEventSchema } from '../schema/event.schema';
|
||||||
|
import { createEvent } from '../server/actions/event-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateEventForm({ accountId, account }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateEventSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
eventDate: '',
|
||||||
|
eventTime: '',
|
||||||
|
endDate: '',
|
||||||
|
location: '',
|
||||||
|
capacity: undefined as number | undefined,
|
||||||
|
minAge: undefined as number | undefined,
|
||||||
|
maxAge: undefined as number | undefined,
|
||||||
|
fee: 0,
|
||||||
|
status: 'planned' as const,
|
||||||
|
registrationDeadline: '',
|
||||||
|
contactName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createEvent, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Veranstaltung erfolgreich erstellt');
|
||||||
|
router.push(`/home/${account}/events-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen der Veranstaltung');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Grunddaten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormField control={form.control} name="name" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Veranstaltungsname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormField control={form.control} name="description" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Beschreibung</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<FormField control={form.control} name="status" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Status</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="planned">Geplant</option>
|
||||||
|
<option value="open">Offen</option>
|
||||||
|
<option value="full">Ausgebucht</option>
|
||||||
|
<option value="running">Laufend</option>
|
||||||
|
<option value="completed">Abgeschlossen</option>
|
||||||
|
<option value="cancelled">Abgesagt</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Datum & Ort</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="eventDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Veranstaltungsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="eventTime" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Uhrzeit</FormLabel><FormControl><Input type="time" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="endDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Enddatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="location" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Veranstaltungsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="registrationDeadline" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Anmeldeschluss</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Teilnehmer & Kosten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="capacity" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Max. Teilnehmer</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={1} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="fee" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Gebühr (€)</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="minAge" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Mindestalter</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="maxAge" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Höchstalter</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0} {...field} value={field.value ?? ''} onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="contactName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Ansprechpartner</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="contactEmail" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="contactPhone" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export {};
|
export { CreateEventForm } from './create-event-form';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
CreateEventSchema,
|
||||||
|
EventRegistrationSchema,
|
||||||
|
CreateHolidayPassSchema,
|
||||||
|
} from '../../schema/event.schema';
|
||||||
|
import { createEventManagementApi } from '../api';
|
||||||
|
|
||||||
|
export const createEvent = authActionClient
|
||||||
|
.inputSchema(CreateEventSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'event.create' }, 'Creating event...');
|
||||||
|
const result = await api.createEvent(input);
|
||||||
|
logger.info({ name: 'event.create' }, 'Event created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerForEvent = authActionClient
|
||||||
|
.inputSchema(EventRegistrationSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'event.register' }, 'Registering for event...');
|
||||||
|
const result = await api.registerForEvent(input);
|
||||||
|
logger.info({ name: 'event.register' }, 'Registered for event');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createHolidayPass = authActionClient
|
||||||
|
.inputSchema(CreateHolidayPassSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'event.createHolidayPass' }, 'Creating holiday pass...');
|
||||||
|
const result = await api.createHolidayPass(input);
|
||||||
|
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
@@ -79,5 +79,15 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data ?? [];
|
return data ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createHolidayPass(input: { accountId: string; name: string; year: number; description?: string; price?: number; validFrom?: string; validUntil?: string }) {
|
||||||
|
const { data, error } = await client.from('holiday_passes').insert({
|
||||||
|
account_id: input.accountId, name: input.name, year: input.year,
|
||||||
|
description: input.description, price: input.price ?? 0,
|
||||||
|
valid_from: input.validFrom, valid_until: input.validUntil,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts",
|
||||||
|
"./actions/*": "./src/server/actions/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
packages/features/finance/src/components/create-invoice-form.tsx
Normal file
190
packages/features/finance/src/components/create-invoice-form.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm, useFieldArray } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { CreateInvoiceSchema } from '../schema/finance.schema';
|
||||||
|
import { createInvoice } from '../server/actions/finance-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateInvoiceForm({ accountId, account }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateInvoiceSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
invoiceNumber: '',
|
||||||
|
recipientName: '',
|
||||||
|
recipientAddress: '',
|
||||||
|
issueDate: new Date().toISOString().split('T')[0]!,
|
||||||
|
dueDate: '',
|
||||||
|
taxRate: 19,
|
||||||
|
notes: '',
|
||||||
|
items: [{ description: '', quantity: 1, unitPrice: 0 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'items',
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedItems = form.watch('items');
|
||||||
|
const watchedTaxRate = form.watch('taxRate');
|
||||||
|
|
||||||
|
const { subtotal, taxAmount, total } = useMemo(() => {
|
||||||
|
const sub = (watchedItems ?? []).reduce((sum, item) => {
|
||||||
|
return sum + (item.quantity || 0) * (item.unitPrice || 0);
|
||||||
|
}, 0);
|
||||||
|
const tax = sub * ((watchedTaxRate || 0) / 100);
|
||||||
|
return { subtotal: sub, taxAmount: tax, total: sub + tax };
|
||||||
|
}, [watchedItems, watchedTaxRate]);
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createInvoice, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Rechnung erfolgreich erstellt');
|
||||||
|
router.push(`/home/${account}/finance-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen der Rechnung');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Rechnungsdaten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="invoiceNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Rechnungsnummer *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="issueDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Rechnungsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="dueDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Fälligkeitsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Empfänger</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="recipientName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Name *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="recipientAddress" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Adresse</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Positionen</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
|
||||||
|
>
|
||||||
|
+ Position hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{fields.map((item, index) => (
|
||||||
|
<div key={item.id} className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12">
|
||||||
|
<div className="sm:col-span-6">
|
||||||
|
<FormField control={form.control} name={`items.${index}.description`} render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Beschreibung *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Menge</FormLabel><FormControl>
|
||||||
|
<Input type="number" min={0.01} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Einzelpreis (€)</FormLabel><FormControl>
|
||||||
|
<Input type="number" step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end sm:col-span-1">
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} className="text-destructive">
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Beträge</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<FormField control={form.control} name="taxRate" render={({ field }) => (
|
||||||
|
<FormItem className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<FormLabel>MwSt.-Satz (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" min={0} step="0.5" className="max-w-[120px]" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<div className="space-y-1 rounded-lg bg-muted p-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Zwischensumme (netto)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>MwSt. ({watchedTaxRate}%)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t pt-1 text-base font-semibold">
|
||||||
|
<span>Gesamtbetrag</span>
|
||||||
|
<span>{formatCurrency(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormField control={form.control} name="notes" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Anmerkungen</FormLabel><FormControl>
|
||||||
|
<textarea {...field} className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
import { createSepaBatch } from '../server/actions/finance-actions';
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
batchType: z.enum(['direct_debit', 'credit_transfer']),
|
||||||
|
description: z.string().optional(),
|
||||||
|
executionDate: z.string().min(1, 'Ausführungsdatum ist erforderlich'),
|
||||||
|
painFormat: z.string().default('pain.008.003.02'),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateSepaBatchForm({ accountId, account }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
batchType: 'direct_debit' as const,
|
||||||
|
description: '',
|
||||||
|
executionDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]!,
|
||||||
|
painFormat: 'pain.008.003.02',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createSepaBatch, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('SEPA-Einzug erstellt');
|
||||||
|
router.push(`/home/${account}/finance/sepa`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SEPA-Einzug erstellen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField control={form.control} name="batchType" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Typ</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="direct_debit">Lastschrift (SEPA Core)</option>
|
||||||
|
<option value="credit_transfer">Überweisung</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="description" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Beschreibung</FormLabel>
|
||||||
|
<FormControl><Input placeholder="z.B. Mitgliedsbeiträge Q1 2026" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="executionDate" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ausführungsdatum *</FormLabel>
|
||||||
|
<FormControl><Input type="date" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Einzug erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export {};
|
export { CreateInvoiceForm } from './create-invoice-form';
|
||||||
|
export { CreateSepaBatchForm } from './create-sepa-batch-form';
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
CreateSepaBatchSchema,
|
||||||
|
AddSepaItemSchema,
|
||||||
|
CreateInvoiceSchema,
|
||||||
|
} from '../../schema/finance.schema';
|
||||||
|
import { createFinanceApi } from '../api';
|
||||||
|
|
||||||
|
export const createSepaBatch = authActionClient
|
||||||
|
.inputSchema(CreateSepaBatchSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createFinanceApi(client);
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
logger.info({ name: 'finance.createSepaBatch' }, 'Creating SEPA batch...');
|
||||||
|
const result = await api.createBatch(input, userId);
|
||||||
|
logger.info({ name: 'finance.createSepaBatch' }, 'SEPA batch created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addSepaItem = authActionClient
|
||||||
|
.inputSchema(AddSepaItemSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createFinanceApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'finance.addSepaItem' }, 'Adding SEPA item...');
|
||||||
|
const result = await api.addItem(input);
|
||||||
|
logger.info({ name: 'finance.addSepaItem' }, 'SEPA item added');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateSepaXml = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
batchId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
creditorName: z.string(),
|
||||||
|
creditorIban: z.string(),
|
||||||
|
creditorBic: z.string(),
|
||||||
|
creditorId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createFinanceApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'finance.generateSepaXml' }, 'Generating SEPA XML...');
|
||||||
|
const result = await api.generateSepaXml(input.batchId, {
|
||||||
|
name: input.creditorName,
|
||||||
|
iban: input.creditorIban,
|
||||||
|
bic: input.creditorBic,
|
||||||
|
creditorId: input.creditorId,
|
||||||
|
});
|
||||||
|
logger.info({ name: 'finance.generateSepaXml' }, 'SEPA XML generated');
|
||||||
|
return { success: true, xml: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createInvoice = authActionClient
|
||||||
|
.inputSchema(CreateInvoiceSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createFinanceApi(client);
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
logger.info({ name: 'finance.createInvoice' }, 'Creating invoice...');
|
||||||
|
const result = await api.createInvoice(input, userId);
|
||||||
|
logger.info({ name: 'finance.createInvoice' }, 'Invoice created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 3: SEPA auto-populate from members
|
||||||
|
export const populateBatchFromMembers = authActionClient
|
||||||
|
.inputSchema(z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createFinanceApi(client);
|
||||||
|
logger.info({ name: 'sepa.populate' }, 'Populating batch from members...');
|
||||||
|
const result = await api.populateBatchFromMembers(input.batchId, input.accountId);
|
||||||
|
logger.info({ name: 'sepa.populate', count: result.addedCount }, 'Populated');
|
||||||
|
return { success: true, addedCount: result.addedCount };
|
||||||
|
});
|
||||||
@@ -148,6 +148,59 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
|
|||||||
return { ...data, items: items ?? [] };
|
return { ...data, items: items ?? [] };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- SEPA auto-populate from members (Gap 3) ---
|
||||||
|
async populateBatchFromMembers(batchId: string, accountId: string) {
|
||||||
|
// Get all active members with active SEPA mandates + dues categories
|
||||||
|
const { data: members, error: memberError } = await client
|
||||||
|
.from('members')
|
||||||
|
.select('id, first_name, last_name, dues_category_id')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('status', 'active');
|
||||||
|
if (memberError) throw memberError;
|
||||||
|
|
||||||
|
const { data: mandates, error: mandateError } = await client
|
||||||
|
.from('sepa_mandates')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.eq('is_primary', true);
|
||||||
|
if (mandateError) throw mandateError;
|
||||||
|
|
||||||
|
const { data: categories, error: catError } = await client
|
||||||
|
.from('dues_categories')
|
||||||
|
.select('id, amount')
|
||||||
|
.eq('account_id', accountId);
|
||||||
|
if (catError) throw catError;
|
||||||
|
|
||||||
|
const mandateMap = new Map((mandates ?? []).map((m: any) => [m.member_id, m]));
|
||||||
|
const categoryMap = new Map((categories ?? []).map((c: any) => [c.id, Number(c.amount)]));
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
for (const member of (members ?? []) as any[]) {
|
||||||
|
const mandate = mandateMap.get(member.id);
|
||||||
|
if (!mandate) continue;
|
||||||
|
|
||||||
|
const amount = member.dues_category_id ? categoryMap.get(member.dues_category_id) ?? 0 : 0;
|
||||||
|
if (amount <= 0) continue;
|
||||||
|
|
||||||
|
const { error } = await client.from('sepa_items').insert({
|
||||||
|
batch_id: batchId,
|
||||||
|
member_id: member.id,
|
||||||
|
debtor_name: `${member.first_name} ${member.last_name}`,
|
||||||
|
debtor_iban: mandate.iban,
|
||||||
|
debtor_bic: mandate.bic,
|
||||||
|
amount,
|
||||||
|
mandate_id: mandate.mandate_reference,
|
||||||
|
mandate_date: mandate.mandate_date,
|
||||||
|
remittance_info: `Mitgliedsbeitrag ${new Date().getFullYear()}`,
|
||||||
|
});
|
||||||
|
if (!error) addedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.recalculateBatchTotals(batchId);
|
||||||
|
return { addedCount };
|
||||||
|
},
|
||||||
|
|
||||||
// --- Utilities ---
|
// --- Utilities ---
|
||||||
validateIban,
|
validateIban,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,23 +12,30 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts",
|
||||||
|
"./actions/*": "./src/server/actions/*.ts",
|
||||||
|
"./lib/*": "./src/lib/*.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hookform/resolvers": "catalog:",
|
||||||
"@kit/next": "workspace:*",
|
"@kit/next": "workspace:*",
|
||||||
"@kit/shared": "workspace:*",
|
"@kit/shared": "workspace:*",
|
||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
|
"@types/papaparse": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
|
"papaparse": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"react-hook-form": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface ApplicationWorkflowProps {
|
||||||
|
applications: Array<Record<string, unknown>>;
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPLICATION_STATUS_LABELS: Record<string, string> = {
|
||||||
|
submitted: 'Eingereicht',
|
||||||
|
review: 'In Prüfung',
|
||||||
|
approved: 'Genehmigt',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getApplicationStatusColor(
|
||||||
|
status: string,
|
||||||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return 'default';
|
||||||
|
case 'submitted':
|
||||||
|
case 'review':
|
||||||
|
return 'outline';
|
||||||
|
case 'rejected':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApplicationWorkflow({
|
||||||
|
applications,
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
}: ApplicationWorkflowProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
const { execute: executeApprove, isPending: isApproving } = useAction(
|
||||||
|
approveApplication,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Antrag genehmigt – Mitglied wurde erstellt');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Genehmigen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeReject, isPending: isRejecting } = useAction(
|
||||||
|
rejectApplication,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Antrag wurde abgelehnt');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Ablehnen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApprove = useCallback(
|
||||||
|
(applicationId: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'Mitglied wird automatisch erstellt. Fortfahren?',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeApprove({ applicationId, accountId });
|
||||||
|
},
|
||||||
|
[executeApprove, accountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReject = useCallback(
|
||||||
|
(applicationId: string) => {
|
||||||
|
const reason = window.prompt(
|
||||||
|
'Bitte geben Sie einen Ablehnungsgrund ein:',
|
||||||
|
);
|
||||||
|
if (reason === null) return; // cancelled
|
||||||
|
executeReject({
|
||||||
|
applicationId,
|
||||||
|
accountId,
|
||||||
|
reviewNotes: reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[executeReject, accountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPending = isApproving || isRejecting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Aufnahmeanträge</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Datum</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{applications.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
Keine Aufnahmeanträge vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
applications.map((app) => {
|
||||||
|
const appId = String(app.id ?? '');
|
||||||
|
const appStatus = String(app.status ?? 'submitted');
|
||||||
|
const isActionable =
|
||||||
|
appStatus === 'submitted' || appStatus === 'review';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={appId} className="border-b">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{String(app.last_name ?? '')},{' '}
|
||||||
|
{String(app.first_name ?? '')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{String(app.email ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{app.created_at
|
||||||
|
? new Date(String(app.created_at)).toLocaleDateString(
|
||||||
|
'de-DE',
|
||||||
|
)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={getApplicationStatusColor(appStatus)}>
|
||||||
|
{APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{isActionable && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleApprove(appId)}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleReject(appId)}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { CreateMemberSchema } from '../schema/member.schema';
|
||||||
|
import { createMember } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string; // slug for redirect
|
||||||
|
duesCategories: Array<{ id: string; name: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(CreateMemberSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId,
|
||||||
|
firstName: '', lastName: '', email: '', phone: '', mobile: '',
|
||||||
|
street: '', houseNumber: '', postalCode: '', city: '', country: 'DE',
|
||||||
|
memberNumber: '', status: 'active' as const, entryDate: new Date().toISOString().split('T')[0]!,
|
||||||
|
iban: '', bic: '', accountHolder: '', gdprConsent: false, notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(createMember, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Mitglied erfolgreich erstellt');
|
||||||
|
router.push(`/home/${account}/members-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="firstName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="lastName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="dateOfBirth" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Geburtsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="gender" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Geschlecht</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="">— Bitte wählen —</option>
|
||||||
|
<option value="male">Männlich</option>
|
||||||
|
<option value="female">Weiblich</option>
|
||||||
|
<option value="diverse">Divers</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="email" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="phone" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="mobile" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="street" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="houseNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="postalCode" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="city" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Mitgliedschaft</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="memberNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="status" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Status</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="inactive">Inaktiv</option>
|
||||||
|
<option value="pending">Ausstehend</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="entryDate" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Eintrittsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
{duesCategories.length > 0 && (
|
||||||
|
<FormField control={form.control} name="duesCategoryId" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Beitragskategorie</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="">— Keine —</option>
|
||||||
|
{duesCategories.map(c => <option key={c.id} value={c.id}>{c.name} ({c.amount} €)</option>)}
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="iban" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input placeholder="DE89 3704 0044 0532 0130 00" {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="bic" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="accountHolder" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Guardian (Gap 4) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Erziehungsberechtigte (Jugend)</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="guardianName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Name Erziehungsberechtigte/r</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lifecycle flags (Gap 4) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Mitgliedschaftsmerkmale</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['isHonorary', 'Ehrenmitglied'],
|
||||||
|
['isFoundingMember', 'Gründungsmitglied'],
|
||||||
|
['isYouth', 'Jugendmitglied'],
|
||||||
|
['isRetiree', 'Rentner/Senior'],
|
||||||
|
['isProbationary', 'Probejahr'],
|
||||||
|
] as const).map(([name, label]) => (
|
||||||
|
<FormField key={name} control={form.control} name={name} render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">{label}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* GDPR granular (Gap 4) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Datenschutz-Einwilligungen</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['gdprConsent', 'Allgemeine Einwilligung'],
|
||||||
|
['gdprNewsletter', 'Newsletter'],
|
||||||
|
['gdprInternet', 'Internet/Homepage'],
|
||||||
|
['gdprPrint', 'Vereinszeitung'],
|
||||||
|
['gdprBirthdayInfo', 'Geburtstagsinfo'],
|
||||||
|
] as const).map(([name, label]) => (
|
||||||
|
<FormField key={name} control={form.control} name={name} render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">{label}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Sonstiges</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="salutation" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="">— Keine —</option>
|
||||||
|
<option value="Herr">Herr</option>
|
||||||
|
<option value="Frau">Frau</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="birthplace" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="street2" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<FormField control={form.control} name="notes" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Notizen</FormLabel><FormControl><textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDuesCategory,
|
||||||
|
deleteDuesCategory,
|
||||||
|
} from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface DuesCategoryManagerProps {
|
||||||
|
categories: Array<Record<string, unknown>>;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_LABELS: Record<string, string> = {
|
||||||
|
monthly: 'Monatlich',
|
||||||
|
quarterly: 'Vierteljährlich',
|
||||||
|
semiannual: 'Halbjährlich',
|
||||||
|
annual: 'Jährlich',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryFormValues {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
interval: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuesCategoryManager({
|
||||||
|
categories,
|
||||||
|
accountId,
|
||||||
|
}: DuesCategoryManagerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CategoryFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
amount: 0,
|
||||||
|
interval: 'annual',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||||
|
createDuesCategory,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Beitragskategorie erstellt');
|
||||||
|
form.reset();
|
||||||
|
setShowForm(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeDelete, isPending: isDeletePending } = useAction(
|
||||||
|
deleteDuesCategory,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Beitragskategorie gelöscht');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: CategoryFormValues) => {
|
||||||
|
executeCreate({
|
||||||
|
accountId,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
amount: Number(values.amount),
|
||||||
|
interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
|
||||||
|
isDefault: values.isDefault,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[executeCreate, accountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(categoryId: string, categoryName: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Beitragskategorie "${categoryName}" wirklich löschen?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeDelete({ categoryId });
|
||||||
|
},
|
||||||
|
[executeDelete],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Beitragskategorien</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showForm ? 'outline' : 'default'}
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Neue Kategorie'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Create Form */}
|
||||||
|
{showForm && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neue Beitragskategorie</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Name *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="z.B. Standardbeitrag"
|
||||||
|
{...form.register('name', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Betrag (€) *</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
{...form.register('amount', {
|
||||||
|
required: true,
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Intervall</label>
|
||||||
|
<select
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
{...form.register('interval')}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monatlich</option>
|
||||||
|
<option value="quarterly">Vierteljährlich</option>
|
||||||
|
<option value="semiannual">Halbjährlich</option>
|
||||||
|
<option value="annual">Jährlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end space-x-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input"
|
||||||
|
{...form.register('isDefault')}
|
||||||
|
/>
|
||||||
|
Standard
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" disabled={isCreating} className="w-full">
|
||||||
|
{isCreating ? 'Erstelle...' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Beschreibung</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">Betrag</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Intervall</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium">Standard</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
Keine Beitragskategorien vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => {
|
||||||
|
const catId = String(cat.id ?? '');
|
||||||
|
const catName = String(cat.name ?? '');
|
||||||
|
const interval = String(cat.interval ?? '');
|
||||||
|
const amount = Number(cat.amount ?? 0);
|
||||||
|
const isDefault = Boolean(cat.is_default);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={catId} className="border-b">
|
||||||
|
<td className="px-4 py-3 font-medium">{catName}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{String(cat.description ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono">
|
||||||
|
{amount.toLocaleString('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{INTERVAL_LABELS[interval] ?? interval}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{isDefault ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isDeletePending}
|
||||||
|
onClick={() => handleDelete(catId, catName)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
import { UpdateMemberSchema } from '../schema/member.schema';
|
||||||
|
import { updateMember } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: Record<string, unknown>;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditMemberForm({ member, account, accountId }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const memberId = String(member.id);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(UpdateMemberSchema),
|
||||||
|
defaultValues: {
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
firstName: String(member.first_name ?? ''),
|
||||||
|
lastName: String(member.last_name ?? ''),
|
||||||
|
email: String(member.email ?? ''),
|
||||||
|
phone: String(member.phone ?? ''),
|
||||||
|
mobile: String(member.mobile ?? ''),
|
||||||
|
street: String(member.street ?? ''),
|
||||||
|
houseNumber: String(member.house_number ?? ''),
|
||||||
|
postalCode: String(member.postal_code ?? ''),
|
||||||
|
city: String(member.city ?? ''),
|
||||||
|
status: String(member.status ?? 'active') as 'active',
|
||||||
|
memberNumber: String(member.member_number ?? ''),
|
||||||
|
salutation: String(member.salutation ?? ''),
|
||||||
|
birthplace: String(member.birthplace ?? ''),
|
||||||
|
street2: String(member.street2 ?? ''),
|
||||||
|
guardianName: String(member.guardian_name ?? ''),
|
||||||
|
guardianPhone: String(member.guardian_phone ?? ''),
|
||||||
|
guardianEmail: String(member.guardian_email ?? ''),
|
||||||
|
iban: String(member.iban ?? ''),
|
||||||
|
bic: String(member.bic ?? ''),
|
||||||
|
accountHolder: String(member.account_holder ?? ''),
|
||||||
|
notes: String(member.notes ?? ''),
|
||||||
|
isHonorary: Boolean(member.is_honorary),
|
||||||
|
isFoundingMember: Boolean(member.is_founding_member),
|
||||||
|
isYouth: Boolean(member.is_youth),
|
||||||
|
isRetiree: Boolean(member.is_retiree),
|
||||||
|
isProbationary: Boolean(member.is_probationary),
|
||||||
|
gdprConsent: Boolean(member.gdpr_consent),
|
||||||
|
gdprNewsletter: Boolean(member.gdpr_newsletter),
|
||||||
|
gdprInternet: Boolean(member.gdpr_internet),
|
||||||
|
gdprPrint: Boolean(member.gdpr_print),
|
||||||
|
gdprBirthdayInfo: Boolean(member.gdpr_birthday_info),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute, isPending } = useAction(updateMember, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Mitglied aktualisiert');
|
||||||
|
router.push(`/home/${account}/members-cms/${memberId}`);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="salutation" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
|
||||||
|
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="">—</option><option value="Herr">Herr</option><option value="Frau">Frau</option>
|
||||||
|
</select>
|
||||||
|
</FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<div />
|
||||||
|
<FormField control={form.control} name="firstName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="lastName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="memberNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="birthplace" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="email" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="phone" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="mobile" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField control={form.control} name="street" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="houseNumber" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="street2" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<div />
|
||||||
|
<FormField control={form.control} name="postalCode" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="city" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="iban" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="bic" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="accountHolder" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Erziehungsberechtigte</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField control={form.control} name="guardianName" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Name</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
|
||||||
|
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Merkmale & Datenschutz</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['isHonorary', 'Ehrenmitglied'], ['isFoundingMember', 'Gründungsmitglied'],
|
||||||
|
['isYouth', 'Jugend'], ['isRetiree', 'Rentner'],
|
||||||
|
['isProbationary', 'Probejahr'],
|
||||||
|
] as const).map(([name, label]) => (
|
||||||
|
<FormField key={name} control={form.control} name={name} render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
|
||||||
|
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">DSGVO-Einwilligungen</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['gdprConsent', 'Allgemein'], ['gdprNewsletter', 'Newsletter'],
|
||||||
|
['gdprInternet', 'Internet'], ['gdprPrint', 'Zeitung'],
|
||||||
|
['gdprBirthdayInfo', 'Geburtstag'],
|
||||||
|
] as const).map(([name, label]) => (
|
||||||
|
<FormField key={name} control={form.control} name={name} render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
|
||||||
|
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Notizen</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField control={form.control} name="notes" render={({ field }) => (
|
||||||
|
<FormItem><FormControl><textarea {...field} rows={4} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
|
||||||
|
)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
export {};
|
export { CreateMemberForm } from './create-member-form';
|
||||||
// Phase 4 components: members-table, member-form, member-detail,
|
export { EditMemberForm } from './edit-member-form';
|
||||||
// application-workflow, dues-category-manager, member-statistics-dashboard
|
export { MembersDataTable } from './members-data-table';
|
||||||
|
export { MemberDetailView } from './member-detail-view';
|
||||||
|
export { ApplicationWorkflow } from './application-workflow';
|
||||||
|
export { DuesCategoryManager } from './dues-category-manager';
|
||||||
|
export { MandateManager } from './mandate-manager';
|
||||||
|
export { MemberImportWizard } from './member-import-wizard';
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import { formatIban } from '../lib/member-utils';
|
||||||
|
import { createMandate, revokeMandate } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface MandateManagerProps {
|
||||||
|
mandates: Array<Record<string, unknown>>;
|
||||||
|
memberId: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEQUENCE_LABELS: Record<string, string> = {
|
||||||
|
FRST: 'Erstlastschrift',
|
||||||
|
RCUR: 'Wiederkehrend',
|
||||||
|
FNAL: 'Letzte',
|
||||||
|
OOFF: 'Einmalig',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMandateStatusColor(
|
||||||
|
status: string,
|
||||||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'default';
|
||||||
|
case 'pending':
|
||||||
|
return 'outline';
|
||||||
|
case 'revoked':
|
||||||
|
case 'expired':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANDATE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
revoked: 'Widerrufen',
|
||||||
|
expired: 'Abgelaufen',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MandateFormValues {
|
||||||
|
mandateReference: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string;
|
||||||
|
accountHolder: string;
|
||||||
|
mandateDate: string;
|
||||||
|
sequence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MandateManager({
|
||||||
|
mandates,
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
}: MandateManagerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<MandateFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
mandateReference: '',
|
||||||
|
iban: '',
|
||||||
|
bic: '',
|
||||||
|
accountHolder: '',
|
||||||
|
mandateDate: new Date().toISOString().split('T')[0]!,
|
||||||
|
sequence: 'FRST',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||||
|
createMandate,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('SEPA-Mandat erstellt');
|
||||||
|
form.reset();
|
||||||
|
setShowForm(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeRevoke, isPending: isRevoking } = useAction(
|
||||||
|
revokeMandate,
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Mandat widerrufen');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Widerrufen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: MandateFormValues) => {
|
||||||
|
executeCreate({
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
mandateReference: values.mandateReference,
|
||||||
|
iban: values.iban,
|
||||||
|
bic: values.bic,
|
||||||
|
accountHolder: values.accountHolder,
|
||||||
|
mandateDate: values.mandateDate,
|
||||||
|
sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[executeCreate, memberId, accountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRevoke = useCallback(
|
||||||
|
(mandateId: string, reference: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Mandat "${reference}" wirklich widerrufen?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeRevoke({ mandateId });
|
||||||
|
},
|
||||||
|
[executeRevoke],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">SEPA-Mandate</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showForm ? 'outline' : 'default'}
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Neues Mandat'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Create Form */}
|
||||||
|
{showForm && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neues SEPA-Mandat</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Mandatsreferenz *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="MANDATE-001"
|
||||||
|
{...form.register('mandateReference', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">IBAN *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="DE89 3704 0044 0532 0130 00"
|
||||||
|
{...form.register('iban', { required: true })}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]/g, '');
|
||||||
|
form.setValue('iban', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">BIC</label>
|
||||||
|
<Input
|
||||||
|
placeholder="COBADEFFXXX"
|
||||||
|
{...form.register('bic')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Kontoinhaber *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
{...form.register('accountHolder', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Mandatsdatum *</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
{...form.register('mandateDate', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Sequenz</label>
|
||||||
|
<select
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
{...form.register('sequence')}
|
||||||
|
>
|
||||||
|
<option value="FRST">FRST – Erstlastschrift</option>
|
||||||
|
<option value="RCUR">RCUR – Wiederkehrend</option>
|
||||||
|
<option value="FNAL">FNAL – Letzte</option>
|
||||||
|
<option value="OOFF">OOFF – Einmalig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<Button type="submit" disabled={isCreating}>
|
||||||
|
{isCreating ? 'Erstelle...' : 'Mandat erstellen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Referenz</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">IBAN</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Kontoinhaber</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Datum</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium">Primär</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mandates.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
Keine SEPA-Mandate vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
mandates.map((mandate) => {
|
||||||
|
const mandateId = String(mandate.id ?? '');
|
||||||
|
const reference = String(mandate.mandate_reference ?? '—');
|
||||||
|
const mandateStatus = String(mandate.status ?? 'pending');
|
||||||
|
const isPrimary = Boolean(mandate.is_primary);
|
||||||
|
const canRevoke =
|
||||||
|
mandateStatus === 'active' || mandateStatus === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={mandateId} className="border-b">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{reference}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{formatIban(mandate.iban as string | null | undefined)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{String(mandate.account_holder ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{mandate.mandate_date
|
||||||
|
? new Date(
|
||||||
|
String(mandate.mandate_date),
|
||||||
|
).toLocaleDateString('de-DE')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={getMandateStatusColor(mandateStatus)}>
|
||||||
|
{MANDATE_STATUS_LABELS[mandateStatus] ?? mandateStatus}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{isPrimary ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{canRevoke && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isRevoking}
|
||||||
|
onClick={() => handleRevoke(mandateId, reference)}
|
||||||
|
>
|
||||||
|
Widerrufen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATUS_LABELS,
|
||||||
|
getMemberStatusColor,
|
||||||
|
formatAddress,
|
||||||
|
formatIban,
|
||||||
|
computeAge,
|
||||||
|
computeMembershipYears,
|
||||||
|
} from '../lib/member-utils';
|
||||||
|
import { deleteMember, updateMember } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
interface MemberDetailViewProps {
|
||||||
|
member: Record<string, unknown>;
|
||||||
|
account: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b py-2 last:border-b-0">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm text-right">{value ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const memberId = String(member.id ?? '');
|
||||||
|
const status = String(member.status ?? 'active');
|
||||||
|
const firstName = String(member.first_name ?? '');
|
||||||
|
const lastName = String(member.last_name ?? '');
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteMember, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Mitglied wurde gekündigt');
|
||||||
|
router.push(`/home/${account}/members-cms`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Kündigen');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateMember, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('Mitglied wurde archiviert');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Archivieren');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!window.confirm(`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeDelete({ memberId, accountId });
|
||||||
|
}, [executeDelete, memberId, accountId, fullName]);
|
||||||
|
|
||||||
|
const handleArchive = useCallback(() => {
|
||||||
|
if (!window.confirm(`Möchten Sie ${fullName} wirklich archivieren?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeUpdate({
|
||||||
|
memberId,
|
||||||
|
accountId,
|
||||||
|
isArchived: true,
|
||||||
|
});
|
||||||
|
}, [executeUpdate, memberId, accountId, fullName]);
|
||||||
|
|
||||||
|
const age = computeAge(member.date_of_birth as string | null | undefined);
|
||||||
|
const membershipYears = computeMembershipYears(member.entry_date as string | null | undefined);
|
||||||
|
const address = formatAddress(member);
|
||||||
|
const iban = formatIban(member.iban as string | null | undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">{fullName}</h1>
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mitgliedsnr. {String(member.member_number ?? '—')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/home/${account}/members-cms/${memberId}/edit`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isUpdating}
|
||||||
|
onClick={handleArchive}
|
||||||
|
>
|
||||||
|
{isUpdating ? 'Archiviere...' : 'Archivieren'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Wird gekündigt...' : 'Kündigen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Cards */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Persönliche Daten */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Persönliche Daten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow label="Vorname" value={firstName} />
|
||||||
|
<DetailRow label="Nachname" value={lastName} />
|
||||||
|
<DetailRow
|
||||||
|
label="Geburtsdatum"
|
||||||
|
value={
|
||||||
|
member.date_of_birth
|
||||||
|
? `${new Date(String(member.date_of_birth)).toLocaleDateString('de-DE')}${age !== null ? ` (${age} Jahre)` : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Geschlecht" value={String(member.gender ?? '—')} />
|
||||||
|
<DetailRow label="Anrede" value={String(member.salutation ?? '—')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Kontakt */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kontakt</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow label="E-Mail" value={String(member.email ?? '—')} />
|
||||||
|
<DetailRow label="Telefon" value={String(member.phone ?? '—')} />
|
||||||
|
<DetailRow label="Mobil" value={String(member.mobile ?? '—')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Adresse */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adresse</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow label="Adresse" value={address || '—'} />
|
||||||
|
<DetailRow label="PLZ" value={String(member.postal_code ?? '—')} />
|
||||||
|
<DetailRow label="Ort" value={String(member.city ?? '—')} />
|
||||||
|
<DetailRow label="Land" value={String(member.country ?? 'DE')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mitgliedschaft */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mitgliedschaft</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DetailRow label="Mitgliedsnr." value={String(member.member_number ?? '—')} />
|
||||||
|
<DetailRow
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Eintrittsdatum"
|
||||||
|
value={
|
||||||
|
member.entry_date
|
||||||
|
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Mitgliedsjahre"
|
||||||
|
value={membershipYears > 0 ? `${membershipYears} Jahre` : '—'}
|
||||||
|
/>
|
||||||
|
<DetailRow label="IBAN" value={iban} />
|
||||||
|
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
|
||||||
|
<DetailRow label="Kontoinhaber" value={String(member.account_holder ?? '—')} />
|
||||||
|
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back */}
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" onClick={() => router.back()}>
|
||||||
|
← Zurück zur Übersicht
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createMember } from '../server/actions/member-actions';
|
||||||
|
|
||||||
|
const MEMBER_FIELDS = [
|
||||||
|
{ key: 'memberNumber', label: 'Mitgliedsnr.' },
|
||||||
|
{ key: 'salutation', label: 'Anrede' },
|
||||||
|
{ key: 'firstName', label: 'Vorname' },
|
||||||
|
{ key: 'lastName', label: 'Nachname' },
|
||||||
|
{ key: 'dateOfBirth', label: 'Geburtsdatum' },
|
||||||
|
{ key: 'email', label: 'E-Mail' },
|
||||||
|
{ key: 'phone', label: 'Telefon' },
|
||||||
|
{ key: 'mobile', label: 'Mobil' },
|
||||||
|
{ key: 'street', label: 'Straße' },
|
||||||
|
{ key: 'houseNumber', label: 'Hausnummer' },
|
||||||
|
{ key: 'postalCode', label: 'PLZ' },
|
||||||
|
{ key: 'city', label: 'Ort' },
|
||||||
|
{ key: 'entryDate', label: 'Eintrittsdatum' },
|
||||||
|
{ key: 'iban', label: 'IBAN' },
|
||||||
|
{ key: 'bic', label: 'BIC' },
|
||||||
|
{ key: 'accountHolder', label: 'Kontoinhaber' },
|
||||||
|
{ key: 'notes', label: 'Notizen' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
|
||||||
|
|
||||||
|
export function MemberImportWizard({ accountId, account }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState<Step>('upload');
|
||||||
|
const [rawData, setRawData] = useState<string[][]>([]);
|
||||||
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
|
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||||
|
const [importResults, setImportResults] = useState<{ success: number; errors: string[] }>({ success: 0, errors: [] });
|
||||||
|
|
||||||
|
const { execute: executeCreate } = useAction(createMember);
|
||||||
|
|
||||||
|
// Step 1: Parse file
|
||||||
|
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
Papa.parse(file, {
|
||||||
|
delimiter: ';',
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
complete: (result) => {
|
||||||
|
const data = result.data as string[][];
|
||||||
|
if (data.length < 2) {
|
||||||
|
toast.error('Datei enthält keine Daten');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHeaders(data[0]!);
|
||||||
|
setRawData(data.slice(1).filter(row => row.some(cell => cell?.trim())));
|
||||||
|
|
||||||
|
// Auto-map by header name similarity
|
||||||
|
const autoMap: Record<string, string> = {};
|
||||||
|
for (const field of MEMBER_FIELDS) {
|
||||||
|
const match = data[0]!.findIndex(h =>
|
||||||
|
h.toLowerCase().includes(field.label.toLowerCase().replace('.', '')) ||
|
||||||
|
h.toLowerCase().includes(field.key.toLowerCase())
|
||||||
|
);
|
||||||
|
if (match >= 0) autoMap[field.key] = String(match);
|
||||||
|
}
|
||||||
|
setMapping(autoMap);
|
||||||
|
setStep('mapping');
|
||||||
|
toast.success(`${data.length - 1} Zeilen erkannt`);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
toast.error(`Fehler beim Lesen: ${err.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Step 3: Execute import
|
||||||
|
const executeImport = useCallback(async () => {
|
||||||
|
setStep('importing');
|
||||||
|
let success = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
const row = rawData[i]!;
|
||||||
|
try {
|
||||||
|
const memberData: Record<string, string> = { accountId };
|
||||||
|
for (const field of MEMBER_FIELDS) {
|
||||||
|
const colIdx = mapping[field.key];
|
||||||
|
if (colIdx !== undefined && row[Number(colIdx)]) {
|
||||||
|
memberData[field.key] = row[Number(colIdx)]!.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memberData.firstName || !memberData.lastName) {
|
||||||
|
errors.push(`Zeile ${i + 2}: Vor-/Nachname fehlt`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeCreate(memberData as any);
|
||||||
|
success++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportResults({ success, errors });
|
||||||
|
setStep('done');
|
||||||
|
toast.success(`${success} Mitglieder importiert`);
|
||||||
|
}, [rawData, mapping, accountId, executeCreate]);
|
||||||
|
|
||||||
|
const getMappedValue = (rowIdx: number, fieldKey: string): string => {
|
||||||
|
const colIdx = mapping[fieldKey];
|
||||||
|
if (colIdx === undefined) return '';
|
||||||
|
return rawData[rowIdx]?.[Number(colIdx)]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
|
||||||
|
const labels = ['Datei hochladen', 'Spalten zuordnen', 'Vorschau & Import', 'Fertig'];
|
||||||
|
const isActive = ['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(step) >= i;
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-center gap-2">
|
||||||
|
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
|
isActive ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||||
|
}`}>{i + 1}</div>
|
||||||
|
<span className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}>{labels[i]}</span>
|
||||||
|
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Upload */}
|
||||||
|
{step === 'upload' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
|
||||||
|
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Semikolon-getrennt (;), UTF-8</p>
|
||||||
|
<input type="file" accept=".csv" onChange={handleFileUpload}
|
||||||
|
className="mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Column mapping */}
|
||||||
|
{step === 'mapping' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{MEMBER_FIELDS.map(field => (
|
||||||
|
<div key={field.key} className="flex items-center gap-4">
|
||||||
|
<span className="w-40 text-sm font-medium">{field.label}</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<select
|
||||||
|
value={mapping[field.key] ?? ''}
|
||||||
|
onChange={(e) => setMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
className="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— Nicht zuordnen —</option>
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<option key={i} value={String(i)}>{h}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{mapping[field.key] !== undefined && rawData[0] && (
|
||||||
|
<span className="text-xs text-muted-foreground">z.B. "{rawData[0][Number(mapping[field.key])]}"</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
|
||||||
|
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Preview + execute */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-auto rounded-md border max-h-96">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="p-2 text-left">#</th>
|
||||||
|
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
|
||||||
|
<th key={f.key} className="p-2 text-left">{f.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rawData.slice(0, 20).map((_, i) => {
|
||||||
|
const hasName = getMappedValue(i, 'firstName') && getMappedValue(i, 'lastName');
|
||||||
|
return (
|
||||||
|
<tr key={i} className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}>
|
||||||
|
<td className="p-2">{i + 1} {!hasName && <AlertTriangle className="inline h-3 w-3 text-destructive" />}</td>
|
||||||
|
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
|
||||||
|
<td key={f.key} className="p-2 max-w-32 truncate">{getMappedValue(i, f.key) || '—'}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setStep('mapping')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
|
||||||
|
<Button onClick={executeImport}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
{rawData.length} Mitglieder importieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Importing */}
|
||||||
|
{step === 'importing' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
<p className="mt-4 text-lg font-semibold">Importiere Mitglieder...</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Bitte warten Sie, bis der Import abgeschlossen ist.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done */}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
|
||||||
|
<h3 className="mt-4 text-xl font-semibold">Import abgeschlossen</h3>
|
||||||
|
<div className="mt-4 flex justify-center gap-4">
|
||||||
|
<Badge variant="default">{importResults.success} erfolgreich</Badge>
|
||||||
|
{importResults.errors.length > 0 && (
|
||||||
|
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{importResults.errors.length > 0 && (
|
||||||
|
<div className="mt-4 max-h-40 overflow-auto rounded-md border p-3 text-left text-xs">
|
||||||
|
{importResults.errors.map((err, i) => (
|
||||||
|
<p key={i} className="text-destructive">{err}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
|
||||||
|
Zur Mitgliederliste
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
|
||||||
|
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||||
|
|
||||||
|
interface MembersDataTableProps {
|
||||||
|
data: Array<Record<string, unknown>>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
account: string;
|
||||||
|
duesCategories: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'Alle' },
|
||||||
|
{ value: 'active', label: 'Aktiv' },
|
||||||
|
{ value: 'inactive', label: 'Inaktiv' },
|
||||||
|
{ value: 'pending', label: 'Ausstehend' },
|
||||||
|
{ value: 'resigned', label: 'Ausgetreten' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function MembersDataTable({
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
account,
|
||||||
|
duesCategories,
|
||||||
|
}: MembersDataTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const currentSearch = searchParams.get('search') ?? '';
|
||||||
|
const currentStatus = searchParams.get('status') ?? '';
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
search: currentSearch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateParams = useCallback(
|
||||||
|
(updates: Record<string, string>) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset to page 1 on filter change
|
||||||
|
if (!('page' in updates)) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const search = form.getValues('search');
|
||||||
|
updateParams({ search });
|
||||||
|
},
|
||||||
|
[form, updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
updateParams({ status: e.target.value });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
updateParams({ page: String(newPage) });
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(memberId: string) => {
|
||||||
|
router.push(`/home/${account}/members-cms/${memberId}`);
|
||||||
|
},
|
||||||
|
[router, account],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Mitglied suchen..."
|
||||||
|
className="w-64"
|
||||||
|
{...form.register('search')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={currentStatus}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/home/${account}/members-cms/new`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Neues Mitglied
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Nr</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Ort</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Eintritt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
Keine Mitglieder gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((member) => {
|
||||||
|
const memberId = String(member.id ?? '');
|
||||||
|
const status = String(member.status ?? 'active');
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={memberId}
|
||||||
|
onClick={() => handleRowClick(memberId)}
|
||||||
|
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{String(member.member_number ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{String(member.last_name ?? '')},{' '}
|
||||||
|
{String(member.first_name ?? '')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{String(member.email ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{String(member.city ?? '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={getMemberStatusColor(status)}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{member.entry_date
|
||||||
|
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
Seite {page} von {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
packages/features/member-management/src/lib/member-utils.ts
Normal file
69
packages/features/member-management/src/lib/member-utils.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Client-side utility functions for member display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function computeAge(dateOfBirth: string | null | undefined): number | null {
|
||||||
|
if (!dateOfBirth) return null;
|
||||||
|
const birth = new Date(dateOfBirth);
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const m = today.getMonth() - birth.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMembershipYears(entryDate: string | null | undefined): number {
|
||||||
|
if (!entryDate) return 0;
|
||||||
|
const entry = new Date(entryDate);
|
||||||
|
const today = new Date();
|
||||||
|
let years = today.getFullYear() - entry.getFullYear();
|
||||||
|
const m = today.getMonth() - entry.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < entry.getDate())) years--;
|
||||||
|
return Math.max(0, years);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
|
||||||
|
if (salutation) return `${salutation} ${firstName} ${lastName}`;
|
||||||
|
return `${firstName} ${lastName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAddress(member: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (member.street) {
|
||||||
|
let line = String(member.street);
|
||||||
|
if (member.house_number) line += ` ${member.house_number}`;
|
||||||
|
parts.push(line);
|
||||||
|
}
|
||||||
|
if (member.street2) parts.push(String(member.street2));
|
||||||
|
if (member.postal_code || member.city) {
|
||||||
|
parts.push(`${member.postal_code ?? ''} ${member.city ?? ''}`.trim());
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIban(iban: string | null | undefined): string {
|
||||||
|
if (!iban) return '—';
|
||||||
|
const cleaned = iban.replace(/\s/g, '');
|
||||||
|
return cleaned.replace(/(.{4})/g, '$1 ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'default';
|
||||||
|
case 'inactive': return 'secondary';
|
||||||
|
case 'pending': return 'outline';
|
||||||
|
case 'resigned':
|
||||||
|
case 'excluded':
|
||||||
|
case 'deceased': return 'destructive';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
resigned: 'Ausgetreten',
|
||||||
|
excluded: 'Ausgeschlossen',
|
||||||
|
deceased: 'Verstorben',
|
||||||
|
};
|
||||||
@@ -33,12 +33,42 @@ export const CreateMemberSchema = z.object({
|
|||||||
accountHolder: z.string().max(128).optional(),
|
accountHolder: z.string().max(128).optional(),
|
||||||
gdprConsent: z.boolean().default(false),
|
gdprConsent: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
// New optional fields
|
||||||
|
salutation: z.string().optional(),
|
||||||
|
street2: z.string().optional(),
|
||||||
|
phone2: z.string().optional(),
|
||||||
|
fax: z.string().optional(),
|
||||||
|
birthplace: z.string().optional(),
|
||||||
|
birthCountry: z.string().default('DE'),
|
||||||
|
isHonorary: z.boolean().default(false),
|
||||||
|
isFoundingMember: z.boolean().default(false),
|
||||||
|
isYouth: z.boolean().default(false),
|
||||||
|
isRetiree: z.boolean().default(false),
|
||||||
|
isProbationary: z.boolean().default(false),
|
||||||
|
isTransferred: z.boolean().default(false),
|
||||||
|
exitDate: z.string().optional(),
|
||||||
|
exitReason: z.string().optional(),
|
||||||
|
guardianName: z.string().optional(),
|
||||||
|
guardianPhone: z.string().optional(),
|
||||||
|
guardianEmail: z.string().optional(),
|
||||||
|
duesYear: z.number().int().optional(),
|
||||||
|
duesPaid: z.boolean().default(false),
|
||||||
|
additionalFees: z.number().default(0),
|
||||||
|
exemptionType: z.string().optional(),
|
||||||
|
exemptionReason: z.string().optional(),
|
||||||
|
exemptionAmount: z.number().optional(),
|
||||||
|
gdprNewsletter: z.boolean().default(false),
|
||||||
|
gdprInternet: z.boolean().default(false),
|
||||||
|
gdprPrint: z.boolean().default(false),
|
||||||
|
gdprBirthdayInfo: z.boolean().default(false),
|
||||||
|
sepaMandateReference: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
||||||
|
|
||||||
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
||||||
memberId: z.string().uuid(),
|
memberId: z.string().uuid(),
|
||||||
|
isArchived: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
||||||
@@ -50,6 +80,77 @@ export const CreateDuesCategorySchema = z.object({
|
|||||||
amount: z.number().min(0),
|
amount: z.number().min(0),
|
||||||
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
|
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
|
||||||
isDefault: z.boolean().default(false),
|
isDefault: z.boolean().default(false),
|
||||||
|
isYouth: z.boolean().default(false),
|
||||||
|
isExit: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateDuesCategoryInput = z.infer<typeof CreateDuesCategorySchema>;
|
export type CreateDuesCategoryInput = z.infer<typeof CreateDuesCategorySchema>;
|
||||||
|
|
||||||
|
export const RejectApplicationSchema = z.object({
|
||||||
|
applicationId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
reviewNotes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateDepartmentSchema = z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateMemberRoleSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
roleName: z.string().min(1),
|
||||||
|
fromDate: z.string().optional(),
|
||||||
|
untilDate: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateMemberHonorSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
honorName: z.string().min(1),
|
||||||
|
honorDate: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateSepaMandateSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
mandateReference: z.string().min(1),
|
||||||
|
iban: z.string().min(15).max(34),
|
||||||
|
bic: z.string().optional(),
|
||||||
|
accountHolder: z.string().min(1),
|
||||||
|
mandateDate: z.string(),
|
||||||
|
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).default('RCUR'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateDuesCategorySchema = z.object({
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
amount: z.number().min(0).optional(),
|
||||||
|
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;
|
||||||
|
|
||||||
|
export const UpdateMandateSchema = z.object({
|
||||||
|
mandateId: z.string().uuid(),
|
||||||
|
iban: z.string().min(15).max(34).optional(),
|
||||||
|
bic: z.string().optional(),
|
||||||
|
accountHolder: z.string().optional(),
|
||||||
|
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
|
||||||
|
});
|
||||||
|
export type UpdateMandateInput = z.infer<typeof UpdateMandateSchema>;
|
||||||
|
|
||||||
|
export const ExportMembersSchema = z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
format: z.enum(['csv', 'excel']).default('csv'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AssignDepartmentSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
departmentId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import {
|
||||||
|
CreateMemberSchema,
|
||||||
|
UpdateMemberSchema,
|
||||||
|
RejectApplicationSchema,
|
||||||
|
CreateDuesCategorySchema,
|
||||||
|
CreateDepartmentSchema,
|
||||||
|
CreateMemberRoleSchema,
|
||||||
|
CreateMemberHonorSchema,
|
||||||
|
CreateSepaMandateSchema,
|
||||||
|
UpdateDuesCategorySchema,
|
||||||
|
UpdateMandateSchema,
|
||||||
|
ExportMembersSchema,
|
||||||
|
AssignDepartmentSchema,
|
||||||
|
} from '../../schema/member.schema';
|
||||||
|
import { createMemberManagementApi } from '../api';
|
||||||
|
|
||||||
|
export const createMember = authActionClient
|
||||||
|
.inputSchema(CreateMemberSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
logger.info({ name: 'member.create' }, 'Creating member...');
|
||||||
|
const result = await api.createMember(input, userId);
|
||||||
|
logger.info({ name: 'member.create' }, 'Member created');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMember = authActionClient
|
||||||
|
.inputSchema(UpdateMemberSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
logger.info({ name: 'member.update' }, 'Updating member...');
|
||||||
|
const result = await api.updateMember(input, userId);
|
||||||
|
logger.info({ name: 'member.update' }, 'Member updated');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteMember = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'member.delete' }, 'Deleting member...');
|
||||||
|
const result = await api.deleteMember(input.memberId);
|
||||||
|
logger.info({ name: 'member.delete' }, 'Member deleted');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approveApplication = authActionClient
|
||||||
|
.inputSchema(
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
logger.info({ name: 'member.approveApplication' }, 'Approving application...');
|
||||||
|
const result = await api.approveApplication(input.applicationId, userId);
|
||||||
|
logger.info({ name: 'member.approveApplication' }, 'Application approved');
|
||||||
|
return { success: true, data: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rejectApplication = authActionClient
|
||||||
|
.inputSchema(RejectApplicationSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
logger.info({ name: 'members.reject-application' }, 'Rejecting application...');
|
||||||
|
await api.rejectApplication(input.applicationId, ctx.user.id, input.reviewNotes);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDuesCategory = authActionClient
|
||||||
|
.inputSchema(CreateDuesCategorySchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.createDuesCategory(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDuesCategory = authActionClient
|
||||||
|
.inputSchema(z.object({ categoryId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.deleteDuesCategory(input.categoryId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDepartment = authActionClient
|
||||||
|
.inputSchema(CreateDepartmentSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.createDepartment(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createMemberRole = authActionClient
|
||||||
|
.inputSchema(CreateMemberRoleSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.createMemberRole(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteMemberRole = authActionClient
|
||||||
|
.inputSchema(z.object({ roleId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.deleteMemberRole(input.roleId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createMemberHonor = authActionClient
|
||||||
|
.inputSchema(CreateMemberHonorSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.createMemberHonor(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteMemberHonor = authActionClient
|
||||||
|
.inputSchema(z.object({ honorId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.deleteMemberHonor(input.honorId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createMandate = authActionClient
|
||||||
|
.inputSchema(CreateSepaMandateSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.createMandate(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revokeMandate = authActionClient
|
||||||
|
.inputSchema(z.object({ mandateId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.revokeMandate(input.mandateId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 1: Update operations
|
||||||
|
export const updateDuesCategory = authActionClient
|
||||||
|
.inputSchema(UpdateDuesCategorySchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.updateDuesCategory(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMandate = authActionClient
|
||||||
|
.inputSchema(UpdateMandateSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const data = await api.updateMandate(input);
|
||||||
|
return { success: true, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 2: Export
|
||||||
|
export const exportMembers = authActionClient
|
||||||
|
.inputSchema(ExportMembersSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
|
||||||
|
return { success: true, csv };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 5: Department assignments
|
||||||
|
export const assignDepartment = authActionClient
|
||||||
|
.inputSchema(AssignDepartmentSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.assignDepartment(input.memberId, input.departmentId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeDepartment = authActionClient
|
||||||
|
.inputSchema(AssignDepartmentSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.removeDepartment(input.memberId, input.departmentId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 2: Excel export
|
||||||
|
export const exportMembersExcel = authActionClient
|
||||||
|
.inputSchema(ExportMembersSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
|
||||||
|
// Return base64 for client-side download
|
||||||
|
return { success: true, base64: buffer.toString('base64'), filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap 6: Member card PDF generation
|
||||||
|
export const generateMemberCards = authActionClient
|
||||||
|
.inputSchema(z.object({
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
memberIds: z.array(z.string().uuid()).optional(),
|
||||||
|
orgName: z.string().default('Verein'),
|
||||||
|
}))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
let query = client.from('members').select('id, first_name, last_name, member_number, entry_date, status')
|
||||||
|
.eq('account_id', input.accountId).eq('status', 'active');
|
||||||
|
if (input.memberIds && input.memberIds.length > 0) {
|
||||||
|
query = query.in('id', input.memberIds);
|
||||||
|
}
|
||||||
|
const { data: members, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const { generateMemberCardsPdf } = await import('../services/member-card-generator');
|
||||||
|
const buffer = await generateMemberCardsPdf(
|
||||||
|
input.orgName,
|
||||||
|
(members ?? []).map((m: any) => ({
|
||||||
|
firstName: m.first_name, lastName: m.last_name,
|
||||||
|
memberNumber: m.member_number ?? '', entryDate: m.entry_date ?? '',
|
||||||
|
status: m.status,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, base64: buffer.toString('base64'), filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Portal Invitations
|
||||||
|
export const inviteMemberToPortal = authActionClient
|
||||||
|
.inputSchema(z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
accountId: z.string().uuid(),
|
||||||
|
email: z.string().email(),
|
||||||
|
}))
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
|
||||||
|
logger.info({ name: 'portal.invite', memberId: input.memberId }, 'Sending portal invitation...');
|
||||||
|
|
||||||
|
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
|
||||||
|
|
||||||
|
// Create auth user for the member if not exists
|
||||||
|
// In production: send invitation email with the token link
|
||||||
|
// For now: create the user directly via admin API
|
||||||
|
logger.info({ name: 'portal.invite', token: invitation.invite_token }, 'Invitation created');
|
||||||
|
|
||||||
|
return { success: true, data: invitation };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revokePortalInvitation = authActionClient
|
||||||
|
.inputSchema(z.object({ invitationId: z.string().uuid() }))
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
await api.revokePortalInvitation(input.invitationId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -65,6 +65,33 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
|||||||
gdpr_consent: input.gdprConsent,
|
gdpr_consent: input.gdprConsent,
|
||||||
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
|
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
|
// New parity fields
|
||||||
|
salutation: input.salutation,
|
||||||
|
street2: input.street2,
|
||||||
|
phone2: input.phone2,
|
||||||
|
fax: input.fax,
|
||||||
|
birthplace: input.birthplace,
|
||||||
|
birth_country: input.birthCountry,
|
||||||
|
is_honorary: input.isHonorary,
|
||||||
|
is_founding_member: input.isFoundingMember,
|
||||||
|
is_youth: input.isYouth,
|
||||||
|
is_retiree: input.isRetiree,
|
||||||
|
is_probationary: input.isProbationary,
|
||||||
|
is_transferred: input.isTransferred,
|
||||||
|
guardian_name: input.guardianName,
|
||||||
|
guardian_phone: input.guardianPhone,
|
||||||
|
guardian_email: input.guardianEmail,
|
||||||
|
dues_year: input.duesYear,
|
||||||
|
dues_paid: input.duesPaid,
|
||||||
|
additional_fees: input.additionalFees,
|
||||||
|
exemption_type: input.exemptionType,
|
||||||
|
exemption_reason: input.exemptionReason,
|
||||||
|
exemption_amount: input.exemptionAmount,
|
||||||
|
gdpr_newsletter: input.gdprNewsletter,
|
||||||
|
gdpr_internet: input.gdprInternet,
|
||||||
|
gdpr_print: input.gdprPrint,
|
||||||
|
gdpr_birthday_info: input.gdprBirthdayInfo,
|
||||||
|
sepa_mandate_reference: input.sepaMandateReference,
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
})
|
})
|
||||||
@@ -89,7 +116,45 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
|||||||
if (input.status !== undefined) updateData.status = input.status;
|
if (input.status !== undefined) updateData.status = input.status;
|
||||||
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
|
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
|
||||||
if (input.iban !== undefined) updateData.iban = input.iban;
|
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||||
|
if (input.bic !== undefined) updateData.bic = input.bic;
|
||||||
|
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
|
||||||
if (input.notes !== undefined) updateData.notes = input.notes;
|
if (input.notes !== undefined) updateData.notes = input.notes;
|
||||||
|
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
|
||||||
|
// New parity fields
|
||||||
|
if (input.salutation !== undefined) updateData.salutation = input.salutation;
|
||||||
|
if (input.street2 !== undefined) updateData.street2 = input.street2;
|
||||||
|
if (input.phone2 !== undefined) updateData.phone2 = input.phone2;
|
||||||
|
if (input.fax !== undefined) updateData.fax = input.fax;
|
||||||
|
if (input.birthplace !== undefined) updateData.birthplace = input.birthplace;
|
||||||
|
if (input.birthCountry !== undefined) updateData.birth_country = input.birthCountry;
|
||||||
|
if (input.title !== undefined) updateData.title = input.title;
|
||||||
|
if (input.dateOfBirth !== undefined) updateData.date_of_birth = input.dateOfBirth;
|
||||||
|
if (input.gender !== undefined) updateData.gender = input.gender;
|
||||||
|
if (input.country !== undefined) updateData.country = input.country;
|
||||||
|
if (input.entryDate !== undefined) updateData.entry_date = input.entryDate;
|
||||||
|
if (input.exitDate !== undefined) updateData.exit_date = input.exitDate;
|
||||||
|
if (input.exitReason !== undefined) updateData.exit_reason = input.exitReason;
|
||||||
|
if (input.isHonorary !== undefined) updateData.is_honorary = input.isHonorary;
|
||||||
|
if (input.isFoundingMember !== undefined) updateData.is_founding_member = input.isFoundingMember;
|
||||||
|
if (input.isYouth !== undefined) updateData.is_youth = input.isYouth;
|
||||||
|
if (input.isRetiree !== undefined) updateData.is_retiree = input.isRetiree;
|
||||||
|
if (input.isProbationary !== undefined) updateData.is_probationary = input.isProbationary;
|
||||||
|
if (input.isTransferred !== undefined) updateData.is_transferred = input.isTransferred;
|
||||||
|
if (input.guardianName !== undefined) updateData.guardian_name = input.guardianName;
|
||||||
|
if (input.guardianPhone !== undefined) updateData.guardian_phone = input.guardianPhone;
|
||||||
|
if (input.guardianEmail !== undefined) updateData.guardian_email = input.guardianEmail;
|
||||||
|
if (input.duesYear !== undefined) updateData.dues_year = input.duesYear;
|
||||||
|
if (input.duesPaid !== undefined) updateData.dues_paid = input.duesPaid;
|
||||||
|
if (input.additionalFees !== undefined) updateData.additional_fees = input.additionalFees;
|
||||||
|
if (input.exemptionType !== undefined) updateData.exemption_type = input.exemptionType;
|
||||||
|
if (input.exemptionReason !== undefined) updateData.exemption_reason = input.exemptionReason;
|
||||||
|
if (input.exemptionAmount !== undefined) updateData.exemption_amount = input.exemptionAmount;
|
||||||
|
if (input.gdprConsent !== undefined) updateData.gdpr_consent = input.gdprConsent;
|
||||||
|
if (input.gdprNewsletter !== undefined) updateData.gdpr_newsletter = input.gdprNewsletter;
|
||||||
|
if (input.gdprInternet !== undefined) updateData.gdpr_internet = input.gdprInternet;
|
||||||
|
if (input.gdprPrint !== undefined) updateData.gdpr_print = input.gdprPrint;
|
||||||
|
if (input.gdprBirthdayInfo !== undefined) updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
|
||||||
|
if (input.sepaMandateReference !== undefined) updateData.sepa_mandate_reference = input.sepaMandateReference;
|
||||||
|
|
||||||
const { data, error } = await (client).from('members')
|
const { data, error } = await (client).from('members')
|
||||||
.update(updateData)
|
.update(updateData)
|
||||||
@@ -186,5 +251,253 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
|||||||
|
|
||||||
return member;
|
return member;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async rejectApplication(applicationId: string, userId: string, reviewNotes?: string) {
|
||||||
|
const { error } = await client.from('membership_applications')
|
||||||
|
.update({ status: 'rejected' as any, reviewed_by: userId, reviewed_at: new Date().toISOString(), review_notes: reviewNotes })
|
||||||
|
.eq('id', applicationId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDuesCategory(input: { accountId: string; name: string; description?: string; amount: number; interval?: string; isDefault?: boolean; isYouth?: boolean; isExit?: boolean }) {
|
||||||
|
const { data, error } = await client.from('dues_categories').insert({
|
||||||
|
account_id: input.accountId, name: input.name, description: input.description,
|
||||||
|
amount: input.amount, interval: input.interval ?? 'yearly',
|
||||||
|
is_default: input.isDefault ?? false, is_youth: input.isYouth ?? false, is_exit: input.isExit ?? false,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDuesCategory(categoryId: string) {
|
||||||
|
const { error } = await client.from('dues_categories').delete().eq('id', categoryId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listDepartments(accountId: string) {
|
||||||
|
const { data, error } = await client.from('member_departments').select('*')
|
||||||
|
.eq('account_id', accountId).order('sort_order');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDepartment(input: { accountId: string; name: string; description?: string }) {
|
||||||
|
const { data, error } = await client.from('member_departments').insert({
|
||||||
|
account_id: input.accountId, name: input.name, description: input.description,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignDepartment(memberId: string, departmentId: string) {
|
||||||
|
const { error } = await client.from('member_department_assignments').insert({
|
||||||
|
member_id: memberId, department_id: departmentId,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeDepartment(memberId: string, departmentId: string) {
|
||||||
|
const { error } = await client.from('member_department_assignments').delete()
|
||||||
|
.eq('member_id', memberId).eq('department_id', departmentId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMemberRoles(memberId: string) {
|
||||||
|
const { data, error } = await client.from('member_roles').select('*')
|
||||||
|
.eq('member_id', memberId).order('from_date', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMemberRole(input: { memberId: string; accountId: string; roleName: string; fromDate?: string; untilDate?: string }) {
|
||||||
|
const { data, error } = await client.from('member_roles').insert({
|
||||||
|
member_id: input.memberId, account_id: input.accountId, role_name: input.roleName,
|
||||||
|
from_date: input.fromDate, until_date: input.untilDate,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMemberRole(roleId: string) {
|
||||||
|
const { error } = await client.from('member_roles').delete().eq('id', roleId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMemberHonors(memberId: string) {
|
||||||
|
const { data, error } = await client.from('member_honors').select('*')
|
||||||
|
.eq('member_id', memberId).order('honor_date', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMemberHonor(input: { memberId: string; accountId: string; honorName: string; honorDate?: string; description?: string }) {
|
||||||
|
const { data, error } = await client.from('member_honors').insert({
|
||||||
|
member_id: input.memberId, account_id: input.accountId, honor_name: input.honorName,
|
||||||
|
honor_date: input.honorDate, description: input.description,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMemberHonor(honorId: string) {
|
||||||
|
const { error } = await client.from('member_honors').delete().eq('id', honorId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMandates(memberId: string) {
|
||||||
|
const { data, error } = await client.from('sepa_mandates').select('*')
|
||||||
|
.eq('member_id', memberId).order('is_primary', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMandate(input: { memberId: string; accountId: string; mandateReference: string; iban: string; bic?: string; accountHolder: string; mandateDate: string; sequence?: string }) {
|
||||||
|
const { data, error } = await client.from('sepa_mandates').insert({
|
||||||
|
member_id: input.memberId, account_id: input.accountId,
|
||||||
|
mandate_reference: input.mandateReference, iban: input.iban, bic: input.bic,
|
||||||
|
account_holder: input.accountHolder, mandate_date: input.mandateDate,
|
||||||
|
sequence: input.sequence ?? 'RCUR',
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeMandate(mandateId: string) {
|
||||||
|
const { error } = await client.from('sepa_mandates')
|
||||||
|
.update({ status: 'revoked' as any }).eq('id', mandateId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkDuplicate(accountId: string, firstName: string, lastName: string, dateOfBirth?: string) {
|
||||||
|
const { data, error } = await client.rpc('check_duplicate_member', {
|
||||||
|
p_account_id: accountId, p_first_name: firstName, p_last_name: lastName,
|
||||||
|
p_date_of_birth: dateOfBirth ?? undefined,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Update operations (Gap 1) ---
|
||||||
|
async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (input.name !== undefined) updateData.name = input.name;
|
||||||
|
if (input.description !== undefined) updateData.description = input.description;
|
||||||
|
if (input.amount !== undefined) updateData.amount = input.amount;
|
||||||
|
if (input.interval !== undefined) updateData.interval = input.interval;
|
||||||
|
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
|
||||||
|
const { data, error } = await client.from('dues_categories').update(updateData).eq('id', input.categoryId).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateMandate(input: { mandateId: string; iban?: string; bic?: string; accountHolder?: string; sequence?: string }) {
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||||
|
if (input.bic !== undefined) updateData.bic = input.bic;
|
||||||
|
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
|
||||||
|
if (input.sequence !== undefined) updateData.sequence = input.sequence;
|
||||||
|
const { data, error } = await client.from('sepa_mandates').update(updateData).eq('id', input.mandateId).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Export (Gap 2) ---
|
||||||
|
async exportMembersCsv(accountId: string, filters?: { status?: string }) {
|
||||||
|
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
|
||||||
|
if (filters?.status) query = query.eq('status', filters.status as any);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
const members = data ?? [];
|
||||||
|
if (members.length === 0) return '';
|
||||||
|
|
||||||
|
const headers = ['Mitgliedsnr.', 'Anrede', 'Vorname', 'Nachname', 'Geburtsdatum', 'E-Mail', 'Telefon', 'Mobil', 'Straße', 'Hausnummer', 'PLZ', 'Ort', 'Status', 'Eintrittsdatum', 'IBAN', 'BIC', 'Kontoinhaber'];
|
||||||
|
const rows = members.map((m) => [
|
||||||
|
m.member_number ?? '', m.salutation ?? '', m.first_name, m.last_name,
|
||||||
|
m.date_of_birth ?? '', m.email ?? '', m.phone ?? '', m.mobile ?? '',
|
||||||
|
m.street ?? '', m.house_number ?? '', m.postal_code ?? '', m.city ?? '',
|
||||||
|
m.status, m.entry_date ?? '', m.iban ?? '', m.bic ?? '', m.account_holder ?? '',
|
||||||
|
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
|
||||||
|
|
||||||
|
return [headers.join(';'), ...rows].join('\n');
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Department assign/remove (Gap 5) ---
|
||||||
|
async getDepartmentAssignments(memberId: string) {
|
||||||
|
const { data, error } = await client.from('member_department_assignments').select('department_id').eq('member_id', memberId);
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []).map((d) => d.department_id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportMembersExcel(accountId: string, filters?: { status?: string }): Promise<Buffer> {
|
||||||
|
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
|
||||||
|
if (filters?.status) query = query.eq('status', filters.status as any);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
const members = data ?? [];
|
||||||
|
|
||||||
|
const ExcelJS = (await import('exceljs')).default;
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Mitglieder');
|
||||||
|
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
|
||||||
|
{ header: 'Anrede', key: 'salutation', width: 10 },
|
||||||
|
{ header: 'Vorname', key: 'first_name', width: 20 },
|
||||||
|
{ header: 'Nachname', key: 'last_name', width: 20 },
|
||||||
|
{ header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
|
||||||
|
{ header: 'E-Mail', key: 'email', width: 30 },
|
||||||
|
{ header: 'Telefon', key: 'phone', width: 18 },
|
||||||
|
{ header: 'Mobil', key: 'mobile', width: 18 },
|
||||||
|
{ header: 'Straße', key: 'street', width: 25 },
|
||||||
|
{ header: 'Hausnummer', key: 'house_number', width: 12 },
|
||||||
|
{ header: 'PLZ', key: 'postal_code', width: 10 },
|
||||||
|
{ header: 'Ort', key: 'city', width: 20 },
|
||||||
|
{ header: 'Status', key: 'status', width: 12 },
|
||||||
|
{ header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
|
||||||
|
{ header: 'IBAN', key: 'iban', width: 30 },
|
||||||
|
{ header: 'BIC', key: 'bic', width: 15 },
|
||||||
|
{ header: 'Kontoinhaber', key: 'account_holder', width: 25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Style header row
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
|
||||||
|
|
||||||
|
for (const m of members) {
|
||||||
|
sheet.addRow(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Portal Invitations ---
|
||||||
|
async inviteMemberToPortal(input: { memberId: string; accountId: string; email: string }, invitedBy: string) {
|
||||||
|
const { data, error } = await client.from('member_portal_invitations').insert({
|
||||||
|
account_id: input.accountId, member_id: input.memberId, email: input.email, invited_by: invitedBy,
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPortalInvitations(accountId: string) {
|
||||||
|
const { data, error } = await client.from('member_portal_invitations').select('*')
|
||||||
|
.eq('account_id', accountId).order('created_at', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokePortalInvitation(invitationId: string) {
|
||||||
|
const { error } = await client.from('member_portal_invitations')
|
||||||
|
.update({ status: 'revoked' as any }).eq('id', invitationId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMemberByUserId(accountId: string, userId: string) {
|
||||||
|
const { data, error } = await client.from('members').select('*')
|
||||||
|
.eq('account_id', accountId).eq('user_id', userId).maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Member card PDF generator using @react-pdf/renderer.
|
||||||
|
* Generates A4 pages with member ID cards in a grid layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderToBuffer } from '@react-pdf/renderer';
|
||||||
|
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { padding: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||||
|
card: {
|
||||||
|
width: '48%', height: 180, border: '1pt solid #ccc', borderRadius: 8,
|
||||||
|
padding: 12, justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
orgName: { fontSize: 10, fontWeight: 'bold', color: '#0d9488', marginBottom: 6 },
|
||||||
|
cardTitle: { fontSize: 7, color: '#888', textTransform: 'uppercase' as const, letterSpacing: 1 },
|
||||||
|
memberName: { fontSize: 14, fontWeight: 'bold', marginTop: 4 },
|
||||||
|
memberNumber: { fontSize: 9, color: '#666', marginTop: 2 },
|
||||||
|
fieldRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||||
|
fieldLabel: { fontSize: 7, color: '#888' },
|
||||||
|
fieldValue: { fontSize: 8 },
|
||||||
|
footer: { fontSize: 6, color: '#aaa', textAlign: 'center' as const, marginTop: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MemberCardData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
memberNumber: string;
|
||||||
|
entryDate: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardPdfProps {
|
||||||
|
orgName: string;
|
||||||
|
members: MemberCardData[];
|
||||||
|
validYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
|
||||||
|
return React.createElement(Document, {},
|
||||||
|
React.createElement(Page, { size: 'A4', style: styles.page },
|
||||||
|
...members.map((m, i) =>
|
||||||
|
React.createElement(View, { key: i, style: styles.card },
|
||||||
|
React.createElement(View, {},
|
||||||
|
React.createElement(Text, { style: styles.orgName }, orgName),
|
||||||
|
React.createElement(Text, { style: styles.cardTitle }, 'MITGLIEDSAUSWEIS'),
|
||||||
|
React.createElement(Text, { style: styles.memberName }, `${m.firstName} ${m.lastName}`),
|
||||||
|
React.createElement(Text, { style: styles.memberNumber }, `Nr. ${m.memberNumber || '—'}`),
|
||||||
|
),
|
||||||
|
React.createElement(View, {},
|
||||||
|
React.createElement(View, { style: styles.fieldRow },
|
||||||
|
React.createElement(View, {},
|
||||||
|
React.createElement(Text, { style: styles.fieldLabel }, 'Mitglied seit'),
|
||||||
|
React.createElement(Text, { style: styles.fieldValue }, m.entryDate || '—'),
|
||||||
|
),
|
||||||
|
React.createElement(View, {},
|
||||||
|
React.createElement(Text, { style: styles.fieldLabel }, 'Gültig'),
|
||||||
|
React.createElement(Text, { style: styles.fieldValue }, String(validYear)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
React.createElement(Text, { style: styles.footer }, `${orgName} — Mitgliedsausweis ${validYear}`),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMemberCardsPdf(
|
||||||
|
orgName: string,
|
||||||
|
members: MemberCardData[],
|
||||||
|
validYear?: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const year = validYear ?? new Date().getFullYear();
|
||||||
|
const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
|
||||||
|
const buffer = await renderToBuffer(doc as any);
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user